@naturalcycles/abba 1.13.1 → 1.14.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 +10 -18
- package/dist/abba.js +70 -74
- package/dist/dao/experiment.dao.d.ts +1 -0
- package/dist/dao/experiment.dao.js +10 -2
- package/dist/migrations/init.sql +1 -0
- package/dist/types.d.ts +5 -7
- package/dist/util.d.ts +12 -2
- package/dist/util.js +33 -3
- package/package.json +1 -1
- package/readme.md +13 -0
- package/src/abba.ts +97 -116
- package/src/dao/experiment.dao.ts +11 -2
- package/src/migrations/init.sql +1 -0
- package/src/types.ts +6 -15
- package/src/util.ts +52 -2
package/dist/abba.d.ts
CHANGED
|
@@ -3,22 +3,19 @@ import { AbbaConfig, Bucket, BucketInput, Experiment, ExperimentWithBuckets, Gen
|
|
|
3
3
|
import { SegmentationData, AssignmentStatistics } from '.';
|
|
4
4
|
export declare class Abba {
|
|
5
5
|
cfg: AbbaConfig;
|
|
6
|
-
constructor(cfg: AbbaConfig);
|
|
7
6
|
private experimentDao;
|
|
8
7
|
private bucketDao;
|
|
9
8
|
private userAssignmentDao;
|
|
9
|
+
constructor(cfg: AbbaConfig);
|
|
10
10
|
/**
|
|
11
|
-
* Returns all
|
|
12
|
-
*
|
|
13
|
-
* Cold method, not cached.
|
|
11
|
+
* Returns all experiments.
|
|
12
|
+
* Cached (see CACHE_TTL)
|
|
14
13
|
*/
|
|
15
14
|
getAllExperiments(): Promise<ExperimentWithBuckets[]>;
|
|
16
15
|
/**
|
|
17
|
-
* Returns
|
|
18
|
-
* Hot method.
|
|
19
|
-
* Cached in-memory for N minutes (currently 10).
|
|
16
|
+
* Returns all experiments.
|
|
20
17
|
*/
|
|
21
|
-
|
|
18
|
+
getAllExperimentsNoCache(): Promise<ExperimentWithBuckets[]>;
|
|
22
19
|
/**
|
|
23
20
|
* Creates a new experiment.
|
|
24
21
|
* Cold method.
|
|
@@ -31,11 +28,15 @@ export declare class Abba {
|
|
|
31
28
|
saveExperiment(experiment: Experiment & {
|
|
32
29
|
id: number;
|
|
33
30
|
}, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
|
|
31
|
+
/**
|
|
32
|
+
* Ensures that mutual exclusions are maintained
|
|
33
|
+
*/
|
|
34
|
+
private updateExclusions;
|
|
34
35
|
/**
|
|
35
36
|
* Delete an experiment. Removes all user assignments and buckets.
|
|
36
37
|
* Cold method.
|
|
37
38
|
*/
|
|
38
|
-
deleteExperiment(
|
|
39
|
+
deleteExperiment(experimentId: number): Promise<void>;
|
|
39
40
|
/**
|
|
40
41
|
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
41
42
|
* Cold method.
|
|
@@ -63,13 +64,4 @@ export declare class Abba {
|
|
|
63
64
|
* Cold method.
|
|
64
65
|
*/
|
|
65
66
|
getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics>;
|
|
66
|
-
/**
|
|
67
|
-
* Generate a new assignment for a given user.
|
|
68
|
-
* Doesn't save it.
|
|
69
|
-
*/
|
|
70
|
-
private generateUserAssignmentData;
|
|
71
|
-
/**
|
|
72
|
-
* Queries to retrieve an existing user assignment for a given experiment
|
|
73
|
-
*/
|
|
74
|
-
private getExistingUserAssignment;
|
|
75
67
|
}
|
package/dist/abba.js
CHANGED
|
@@ -9,45 +9,30 @@ const util_1 = require("./util");
|
|
|
9
9
|
const experiment_dao_1 = require("./dao/experiment.dao");
|
|
10
10
|
const userAssignment_dao_1 = require("./dao/userAssignment.dao");
|
|
11
11
|
const bucket_dao_1 = require("./dao/bucket.dao");
|
|
12
|
+
/**
|
|
13
|
+
* 10 minutes
|
|
14
|
+
*/
|
|
12
15
|
const CACHE_TTL = 600000; // 10 minutes
|
|
13
16
|
class Abba {
|
|
14
17
|
constructor(cfg) {
|
|
15
18
|
this.cfg = cfg;
|
|
16
|
-
|
|
17
|
-
this.
|
|
18
|
-
this.
|
|
19
|
-
this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(db);
|
|
19
|
+
this.experimentDao = (0, experiment_dao_1.experimentDao)(this.cfg.db);
|
|
20
|
+
this.bucketDao = (0, bucket_dao_1.bucketDao)(this.cfg.db);
|
|
21
|
+
this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(this.cfg.db);
|
|
20
22
|
}
|
|
21
23
|
/**
|
|
22
|
-
* Returns all
|
|
23
|
-
*
|
|
24
|
-
* Cold method, not cached.
|
|
24
|
+
* Returns all experiments.
|
|
25
|
+
* Cached (see CACHE_TTL)
|
|
25
26
|
*/
|
|
26
27
|
async getAllExperiments() {
|
|
27
|
-
|
|
28
|
-
const buckets = await this.bucketDao
|
|
29
|
-
.query()
|
|
30
|
-
.filter('experimentId', 'in', experiments.map(e => e.id))
|
|
31
|
-
.runQuery();
|
|
32
|
-
return experiments.map(experiment => ({
|
|
33
|
-
...experiment,
|
|
34
|
-
buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
|
|
35
|
-
}));
|
|
28
|
+
return await this.getAllExperimentsNoCache();
|
|
36
29
|
}
|
|
37
30
|
/**
|
|
38
|
-
* Returns
|
|
39
|
-
* Hot method.
|
|
40
|
-
* Cached in-memory for N minutes (currently 10).
|
|
31
|
+
* Returns all experiments.
|
|
41
32
|
*/
|
|
42
|
-
async
|
|
43
|
-
const experiments = await this.experimentDao
|
|
44
|
-
|
|
45
|
-
.filter('status', '!=', types_1.AssignmentStatus.Inactive)
|
|
46
|
-
.runQuery();
|
|
47
|
-
const buckets = await this.bucketDao
|
|
48
|
-
.query()
|
|
49
|
-
.filter('experimentId', 'in', experiments.map(e => e.id))
|
|
50
|
-
.runQuery();
|
|
33
|
+
async getAllExperimentsNoCache() {
|
|
34
|
+
const experiments = await this.experimentDao.getAll();
|
|
35
|
+
const buckets = await this.bucketDao.getAll();
|
|
51
36
|
return experiments.map(experiment => ({
|
|
52
37
|
...experiment,
|
|
53
38
|
buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
|
|
@@ -61,10 +46,12 @@ class Abba {
|
|
|
61
46
|
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
62
47
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
63
48
|
}
|
|
64
|
-
await this.experimentDao.save(experiment);
|
|
49
|
+
const created = await this.experimentDao.save(experiment);
|
|
50
|
+
const createdbuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id })));
|
|
51
|
+
await this.updateExclusions(created.id, created.exclusions);
|
|
65
52
|
return {
|
|
66
|
-
...
|
|
67
|
-
buckets:
|
|
53
|
+
...created,
|
|
54
|
+
buckets: createdbuckets,
|
|
68
55
|
};
|
|
69
56
|
}
|
|
70
57
|
/**
|
|
@@ -75,18 +62,43 @@ class Abba {
|
|
|
75
62
|
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
76
63
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
77
64
|
}
|
|
78
|
-
await this.experimentDao.save(experiment);
|
|
65
|
+
const updatedExperiment = await this.experimentDao.save(experiment);
|
|
66
|
+
const updatedBuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id })));
|
|
67
|
+
await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
|
|
79
68
|
return {
|
|
80
|
-
...
|
|
81
|
-
buckets:
|
|
69
|
+
...updatedExperiment,
|
|
70
|
+
buckets: updatedBuckets,
|
|
82
71
|
};
|
|
83
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Ensures that mutual exclusions are maintained
|
|
75
|
+
*/
|
|
76
|
+
async updateExclusions(experimentId, updatedExclusions) {
|
|
77
|
+
const experiments = await this.experimentDao.getAll();
|
|
78
|
+
const requiresUpdating = [];
|
|
79
|
+
experiments.map(experiment => {
|
|
80
|
+
// Make sure it's mutual
|
|
81
|
+
if (updatedExclusions.includes(experiment.id) &&
|
|
82
|
+
!experiment.exclusions.includes(experimentId)) {
|
|
83
|
+
experiment.exclusions.push(experimentId);
|
|
84
|
+
requiresUpdating.push(experiment);
|
|
85
|
+
}
|
|
86
|
+
// Make sure it's mutual
|
|
87
|
+
if (!updatedExclusions.includes(experiment.id) &&
|
|
88
|
+
experiment.exclusions.includes(experimentId)) {
|
|
89
|
+
experiment.exclusions = experiment.exclusions.filter(id => id !== experimentId);
|
|
90
|
+
requiresUpdating.push(experiment);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
await this.experimentDao.saveBatch(requiresUpdating);
|
|
94
|
+
}
|
|
84
95
|
/**
|
|
85
96
|
* Delete an experiment. Removes all user assignments and buckets.
|
|
86
97
|
* Cold method.
|
|
87
98
|
*/
|
|
88
|
-
async deleteExperiment(
|
|
89
|
-
await this.experimentDao.deleteById(
|
|
99
|
+
async deleteExperiment(experimentId) {
|
|
100
|
+
await this.experimentDao.deleteById(experimentId);
|
|
101
|
+
await this.updateExclusions(experimentId, []);
|
|
90
102
|
}
|
|
91
103
|
/**
|
|
92
104
|
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
@@ -98,7 +110,8 @@ class Abba {
|
|
|
98
110
|
* @param segmentationData Required if existingOnly is false
|
|
99
111
|
*/
|
|
100
112
|
async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
|
|
101
|
-
const
|
|
113
|
+
const existingAssignments = await this.getAllExistingUserAssignments(userId);
|
|
114
|
+
const existing = existingAssignments.find(a => a.experimentId === experimentId);
|
|
102
115
|
if (existing) {
|
|
103
116
|
let bucketKey = null;
|
|
104
117
|
if (existing.bucketId) {
|
|
@@ -109,18 +122,20 @@ class Abba {
|
|
|
109
122
|
}
|
|
110
123
|
if (existingOnly)
|
|
111
124
|
return null;
|
|
112
|
-
const
|
|
113
|
-
|
|
125
|
+
const experiments = await this.getAllExperiments();
|
|
126
|
+
const experiment = experiments.find(e => e.id === experimentId);
|
|
127
|
+
(0, js_lib_1._assert)(experiment, `Experiment does not exist: ${experimentId}`);
|
|
128
|
+
const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
|
|
129
|
+
if (!(0, util_1.canGenerateNewAssignments)(experiment, exclusionSet))
|
|
114
130
|
return null;
|
|
115
131
|
(0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
116
|
-
const
|
|
117
|
-
const assignment = this.generateUserAssignmentData({ ...experiment, buckets }, userId, segmentationData);
|
|
132
|
+
const assignment = (0, util_1.generateUserAssignmentData)(experiment, userId, segmentationData);
|
|
118
133
|
if (!assignment)
|
|
119
134
|
return null;
|
|
120
135
|
const saved = await this.userAssignmentDao.save(assignment);
|
|
121
136
|
return {
|
|
122
137
|
...saved,
|
|
123
|
-
bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
|
|
138
|
+
bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
|
|
124
139
|
};
|
|
125
140
|
}
|
|
126
141
|
/**
|
|
@@ -137,11 +152,16 @@ class Abba {
|
|
|
137
152
|
* Hot method.
|
|
138
153
|
*/
|
|
139
154
|
async generateUserAssignments(userId, segmentationData, existingOnly = false) {
|
|
140
|
-
const experiments = await this.
|
|
155
|
+
const experiments = await this.getAllExperiments();
|
|
156
|
+
const existingAssignments = await this.getAllExistingUserAssignments(userId);
|
|
157
|
+
const exclusionSet = (0, util_1.getUserExclusionSet)(experiments, existingAssignments);
|
|
141
158
|
const assignments = [];
|
|
142
159
|
const newAssignments = [];
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
// Shuffling means that randomisation occurs in the mutual exclusion
|
|
161
|
+
// 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
|
|
162
|
+
// This is simmpler than trying to resolve after assignments have already been determined
|
|
163
|
+
const availableExperiments = (0, js_lib_1._shuffle)(experiments.filter(e => e.status === types_1.AssignmentStatus.Active || e.status === types_1.AssignmentStatus.Paused));
|
|
164
|
+
for (const experiment of availableExperiments) {
|
|
145
165
|
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
|
|
146
166
|
if (existing) {
|
|
147
167
|
assignments.push({
|
|
@@ -149,8 +169,8 @@ class Abba {
|
|
|
149
169
|
bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
150
170
|
});
|
|
151
171
|
}
|
|
152
|
-
else if (!existingOnly && (0, util_1.canGenerateNewAssignments)(experiment)) {
|
|
153
|
-
const assignment =
|
|
172
|
+
else if (!existingOnly && (0, util_1.canGenerateNewAssignments)(experiment, exclusionSet)) {
|
|
173
|
+
const assignment = (0, util_1.generateUserAssignmentData)(experiment, userId, segmentationData);
|
|
154
174
|
if (assignment) {
|
|
155
175
|
const created = this.userAssignmentDao.create(assignment);
|
|
156
176
|
newAssignments.push(created);
|
|
@@ -158,6 +178,8 @@ class Abba {
|
|
|
158
178
|
...created,
|
|
159
179
|
bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
|
|
160
180
|
});
|
|
181
|
+
// Prevent future exclusion clashes
|
|
182
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
161
183
|
}
|
|
162
184
|
}
|
|
163
185
|
}
|
|
@@ -185,34 +207,8 @@ class Abba {
|
|
|
185
207
|
});
|
|
186
208
|
return statistics;
|
|
187
209
|
}
|
|
188
|
-
/**
|
|
189
|
-
* Generate a new assignment for a given user.
|
|
190
|
-
* Doesn't save it.
|
|
191
|
-
*/
|
|
192
|
-
generateUserAssignmentData(experiment, userId, segmentationData) {
|
|
193
|
-
const segmentationMatch = (0, util_1.validateSegmentationRules)(experiment.rules, segmentationData);
|
|
194
|
-
if (!segmentationMatch)
|
|
195
|
-
return null;
|
|
196
|
-
const bucket = (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets);
|
|
197
|
-
return {
|
|
198
|
-
userId,
|
|
199
|
-
experimentId: experiment.id,
|
|
200
|
-
bucketId: bucket?.id || null,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Queries to retrieve an existing user assignment for a given experiment
|
|
205
|
-
*/
|
|
206
|
-
async getExistingUserAssignment(experimentId, userId) {
|
|
207
|
-
const [assignment] = await this.userAssignmentDao
|
|
208
|
-
.query()
|
|
209
|
-
.filterEq('userId', userId)
|
|
210
|
-
.filterEq('experimentId', experimentId)
|
|
211
|
-
.runQuery();
|
|
212
|
-
return assignment || null;
|
|
213
|
-
}
|
|
214
210
|
}
|
|
215
211
|
tslib_1.__decorate([
|
|
216
212
|
(0, js_lib_1._AsyncMemo)({ cacheFactory: () => new nodejs_lib_1.LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
217
|
-
], Abba.prototype, "
|
|
213
|
+
], Abba.prototype, "getAllExperiments", null);
|
|
218
214
|
exports.Abba = Abba;
|
|
@@ -3,6 +3,7 @@ import { Saved } from '@naturalcycles/js-lib';
|
|
|
3
3
|
import { BaseExperiment, Experiment } from '../types';
|
|
4
4
|
type ExperimentDBM = Saved<BaseExperiment> & {
|
|
5
5
|
rules: string | null;
|
|
6
|
+
exclusions: string | null;
|
|
6
7
|
};
|
|
7
8
|
export declare class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {
|
|
8
9
|
}
|
|
@@ -12,8 +12,16 @@ const experimentDao = (db) => new ExperimentDao({
|
|
|
12
12
|
idType: 'number',
|
|
13
13
|
assignGeneratedIds: true,
|
|
14
14
|
hooks: {
|
|
15
|
-
beforeBMToDBM: bm => ({
|
|
16
|
-
|
|
15
|
+
beforeBMToDBM: bm => ({
|
|
16
|
+
...bm,
|
|
17
|
+
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
18
|
+
exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
|
|
19
|
+
}),
|
|
20
|
+
beforeDBMToBM: dbm => ({
|
|
21
|
+
...dbm,
|
|
22
|
+
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
23
|
+
exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
|
|
24
|
+
}),
|
|
17
25
|
},
|
|
18
26
|
});
|
|
19
27
|
exports.experimentDao = experimentDao;
|
package/dist/migrations/init.sql
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CommonDB } from '@naturalcycles/db-lib';
|
|
2
|
-
import { BaseDBEntity, Saved } from '@naturalcycles/js-lib';
|
|
2
|
+
import { AnyObject, BaseDBEntity, Saved } from '@naturalcycles/js-lib';
|
|
3
3
|
export interface AbbaConfig {
|
|
4
4
|
db: CommonDB;
|
|
5
5
|
}
|
|
@@ -13,6 +13,7 @@ export type BaseExperiment = BaseDBEntity<number> & {
|
|
|
13
13
|
};
|
|
14
14
|
export type Experiment = BaseExperiment & {
|
|
15
15
|
rules: SegmentationRule[];
|
|
16
|
+
exclusions: number[];
|
|
16
17
|
};
|
|
17
18
|
export type ExperimentWithBuckets = Saved<Experiment> & {
|
|
18
19
|
buckets: Saved<Bucket>[];
|
|
@@ -22,11 +23,7 @@ export interface BucketInput {
|
|
|
22
23
|
key: string;
|
|
23
24
|
ratio: number;
|
|
24
25
|
}
|
|
25
|
-
export type Bucket = BaseDBEntity<number> &
|
|
26
|
-
experimentId: number;
|
|
27
|
-
key: string;
|
|
28
|
-
ratio: number;
|
|
29
|
-
};
|
|
26
|
+
export type Bucket = BaseDBEntity<number> & BucketInput;
|
|
30
27
|
export type UserAssignment = BaseDBEntity<number> & {
|
|
31
28
|
userId: string;
|
|
32
29
|
experimentId: number;
|
|
@@ -35,7 +32,7 @@ export type UserAssignment = BaseDBEntity<number> & {
|
|
|
35
32
|
export type GeneratedUserAssignment = Saved<UserAssignment> & {
|
|
36
33
|
bucketKey: string | null;
|
|
37
34
|
};
|
|
38
|
-
export type SegmentationData =
|
|
35
|
+
export type SegmentationData = AnyObject;
|
|
39
36
|
export declare enum AssignmentStatus {
|
|
40
37
|
/**
|
|
41
38
|
* Will return existing assignments and generate new assignments
|
|
@@ -61,3 +58,4 @@ export interface AssignmentStatistics {
|
|
|
61
58
|
[id: string]: number;
|
|
62
59
|
};
|
|
63
60
|
}
|
|
61
|
+
export type ExclusionSet = Set<number>;
|
package/dist/util.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { Saved } from '@naturalcycles/js-lib';
|
|
2
|
-
import { Bucket, Experiment, SegmentationData, SegmentationRule } from './types';
|
|
2
|
+
import { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, UserAssignment } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Generate a new assignment for a given user.
|
|
5
|
+
* Doesn't save it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const generateUserAssignmentData: (experiment: ExperimentWithBuckets, userId: string, segmentationData: SegmentationData) => UserAssignment | null;
|
|
3
8
|
/**
|
|
4
9
|
* Generate a random number between 0 and 100
|
|
5
10
|
*/
|
|
@@ -31,4 +36,9 @@ export declare const validateSegmentationRule: (rule: SegmentationRule, data: Se
|
|
|
31
36
|
/**
|
|
32
37
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
33
38
|
*/
|
|
34
|
-
export declare const canGenerateNewAssignments: (experiment: Experiment) => boolean;
|
|
39
|
+
export declare const canGenerateNewAssignments: (experiment: Experiment, exclusionSet: ExclusionSet) => boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
42
|
+
* based on a combination of existing assignments and mutual exclusion configuration
|
|
43
|
+
*/
|
|
44
|
+
export declare const getUserExclusionSet: (experiments: Experiment[], existingAssignments: UserAssignment[]) => ExclusionSet;
|
package/dist/util.js
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.canGenerateNewAssignments = exports.validateSegmentationRule = exports.validateSegmentationRules = exports.validateTotalBucketRatio = exports.determineBucket = exports.determineAssignment = exports.rollDie = void 0;
|
|
3
|
+
exports.getUserExclusionSet = exports.canGenerateNewAssignments = exports.validateSegmentationRule = exports.validateSegmentationRules = exports.validateTotalBucketRatio = exports.determineBucket = exports.determineAssignment = exports.rollDie = exports.generateUserAssignmentData = void 0;
|
|
4
4
|
const js_lib_1 = require("@naturalcycles/js-lib");
|
|
5
5
|
const semver_1 = require("semver");
|
|
6
6
|
const types_1 = require("./types");
|
|
7
|
+
/**
|
|
8
|
+
* Generate a new assignment for a given user.
|
|
9
|
+
* Doesn't save it.
|
|
10
|
+
*/
|
|
11
|
+
const generateUserAssignmentData = (experiment, userId, segmentationData) => {
|
|
12
|
+
const segmentationMatch = (0, exports.validateSegmentationRules)(experiment.rules, segmentationData);
|
|
13
|
+
if (!segmentationMatch)
|
|
14
|
+
return null;
|
|
15
|
+
const bucket = (0, exports.determineAssignment)(experiment.sampling, experiment.buckets);
|
|
16
|
+
return {
|
|
17
|
+
userId,
|
|
18
|
+
experimentId: experiment.id,
|
|
19
|
+
bucketId: bucket?.id || null,
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
exports.generateUserAssignmentData = generateUserAssignmentData;
|
|
7
23
|
/**
|
|
8
24
|
* Generate a random number between 0 and 100
|
|
9
25
|
*/
|
|
@@ -97,8 +113,22 @@ exports.validateSegmentationRule = validateSegmentationRule;
|
|
|
97
113
|
/**
|
|
98
114
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
99
115
|
*/
|
|
100
|
-
const canGenerateNewAssignments = (experiment) => {
|
|
101
|
-
return (experiment.
|
|
116
|
+
const canGenerateNewAssignments = (experiment, exclusionSet) => {
|
|
117
|
+
return (!exclusionSet.has(experiment.id) &&
|
|
118
|
+
experiment.status === types_1.AssignmentStatus.Active &&
|
|
102
119
|
(0, js_lib_1.localDate)().isBetween((0, js_lib_1.localDate)(experiment.startDateIncl.toISOString()), (0, js_lib_1.localDate)(experiment.endDateExcl.toISOString()), '[)'));
|
|
103
120
|
};
|
|
104
121
|
exports.canGenerateNewAssignments = canGenerateNewAssignments;
|
|
122
|
+
/**
|
|
123
|
+
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
124
|
+
* based on a combination of existing assignments and mutual exclusion configuration
|
|
125
|
+
*/
|
|
126
|
+
const getUserExclusionSet = (experiments, existingAssignments) => {
|
|
127
|
+
const exclusionSet = new Set();
|
|
128
|
+
existingAssignments.forEach(assignment => {
|
|
129
|
+
const experiment = experiments.find(e => e.id === assignment.experimentId);
|
|
130
|
+
experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId));
|
|
131
|
+
});
|
|
132
|
+
return exclusionSet;
|
|
133
|
+
};
|
|
134
|
+
exports.getUserExclusionSet = getUserExclusionSet;
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
</li>
|
|
28
28
|
<li><a href="#usage">Usage</a></li>
|
|
29
29
|
<li><a href="#segmentation">Segmentation</a></li>
|
|
30
|
+
<li><a href="#exclusion">Mutual Exclusion</a></li>
|
|
30
31
|
</ol>
|
|
31
32
|
</details>
|
|
32
33
|
|
|
@@ -41,6 +42,8 @@
|
|
|
41
42
|
- **Bucket:** An allocation that defines what variant of a particular experience the user will have
|
|
42
43
|
- **Start/End dates:** The timeframe that assignments will be generated for this experiment when
|
|
43
44
|
active
|
|
45
|
+
- **Mutual Exclusion:**
|
|
46
|
+
[See here](https://docs.developers.optimizely.com/full-stack-experimentation/docs/mutually-exclusive-experiments)
|
|
44
47
|
|
|
45
48
|
<!-- BUILTWITH -->
|
|
46
49
|
|
|
@@ -287,3 +290,13 @@ Example segmentation data:
|
|
|
287
290
|
```
|
|
288
291
|
|
|
289
292
|
<p align="right">(<a href="#top">back to top</a>)</p>
|
|
293
|
+
|
|
294
|
+
<div id="exclusion"></div>
|
|
295
|
+
|
|
296
|
+
## Mutual Excluusion
|
|
297
|
+
|
|
298
|
+
Mutual exclusion is configured per-experiment. If an experiment is listed as mutually exclusive with
|
|
299
|
+
another experiment(s) then new assignments will only be generated with one of the experiments and
|
|
300
|
+
will never be created for the other(s)
|
|
301
|
+
|
|
302
|
+
<p align="right">(<a href="#top">back to top</a>)</p>
|
package/src/abba.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _assert, _AsyncMemo, pMap, Saved } from '@naturalcycles/js-lib'
|
|
1
|
+
import { _assert, _AsyncMemo, _shuffle, pMap, Saved } from '@naturalcycles/js-lib'
|
|
2
2
|
import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
|
|
3
3
|
import {
|
|
4
4
|
AbbaConfig,
|
|
@@ -12,72 +12,42 @@ import {
|
|
|
12
12
|
} from './types'
|
|
13
13
|
import {
|
|
14
14
|
canGenerateNewAssignments,
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
generateUserAssignmentData,
|
|
16
|
+
getUserExclusionSet,
|
|
17
17
|
validateTotalBucketRatio,
|
|
18
18
|
} from './util'
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
19
|
+
import { experimentDao } from './dao/experiment.dao'
|
|
20
|
+
import { userAssignmentDao } from './dao/userAssignment.dao'
|
|
21
|
+
import { bucketDao } from './dao/bucket.dao'
|
|
22
22
|
import { SegmentationData, AssignmentStatistics } from '.'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 10 minutes
|
|
26
|
+
*/
|
|
24
27
|
const CACHE_TTL = 600_000 // 10 minutes
|
|
25
28
|
|
|
26
29
|
export class Abba {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
this.bucketDao = bucketDao(db)
|
|
31
|
-
this.userAssignmentDao = userAssignmentDao(db)
|
|
32
|
-
}
|
|
30
|
+
private experimentDao = experimentDao(this.cfg.db)
|
|
31
|
+
private bucketDao = bucketDao(this.cfg.db)
|
|
32
|
+
private userAssignmentDao = userAssignmentDao(this.cfg.db)
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
private bucketDao: BucketDao
|
|
36
|
-
private userAssignmentDao: UserAssignmentDao
|
|
34
|
+
constructor(public cfg: AbbaConfig) {}
|
|
37
35
|
|
|
38
36
|
/**
|
|
39
|
-
* Returns all
|
|
40
|
-
*
|
|
41
|
-
* Cold method, not cached.
|
|
37
|
+
* Returns all experiments.
|
|
38
|
+
* Cached (see CACHE_TTL)
|
|
42
39
|
*/
|
|
40
|
+
@_AsyncMemo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
43
41
|
async getAllExperiments(): Promise<ExperimentWithBuckets[]> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const buckets = await this.bucketDao
|
|
47
|
-
.query()
|
|
48
|
-
.filter(
|
|
49
|
-
'experimentId',
|
|
50
|
-
'in',
|
|
51
|
-
experiments.map(e => e.id),
|
|
52
|
-
)
|
|
53
|
-
.runQuery()
|
|
54
|
-
|
|
55
|
-
return experiments.map(experiment => ({
|
|
56
|
-
...experiment,
|
|
57
|
-
buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
|
|
58
|
-
}))
|
|
42
|
+
return await this.getAllExperimentsNoCache()
|
|
59
43
|
}
|
|
60
44
|
|
|
61
45
|
/**
|
|
62
|
-
* Returns
|
|
63
|
-
* Hot method.
|
|
64
|
-
* Cached in-memory for N minutes (currently 10).
|
|
46
|
+
* Returns all experiments.
|
|
65
47
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
.query()
|
|
70
|
-
.filter('status', '!=', AssignmentStatus.Inactive)
|
|
71
|
-
.runQuery()
|
|
72
|
-
|
|
73
|
-
const buckets = await this.bucketDao
|
|
74
|
-
.query()
|
|
75
|
-
.filter(
|
|
76
|
-
'experimentId',
|
|
77
|
-
'in',
|
|
78
|
-
experiments.map(e => e.id),
|
|
79
|
-
)
|
|
80
|
-
.runQuery()
|
|
48
|
+
async getAllExperimentsNoCache(): Promise<ExperimentWithBuckets[]> {
|
|
49
|
+
const experiments = await this.experimentDao.getAll()
|
|
50
|
+
const buckets = await this.bucketDao.getAll()
|
|
81
51
|
|
|
82
52
|
return experiments.map(experiment => ({
|
|
83
53
|
...experiment,
|
|
@@ -97,13 +67,16 @@ export class Abba {
|
|
|
97
67
|
validateTotalBucketRatio(buckets)
|
|
98
68
|
}
|
|
99
69
|
|
|
100
|
-
await this.experimentDao.save(experiment)
|
|
70
|
+
const created = await this.experimentDao.save(experiment)
|
|
71
|
+
const createdbuckets = await this.bucketDao.saveBatch(
|
|
72
|
+
buckets.map(b => ({ ...b, experimentId: experiment.id })),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
await this.updateExclusions(created.id, created.exclusions)
|
|
101
76
|
|
|
102
77
|
return {
|
|
103
|
-
...
|
|
104
|
-
buckets:
|
|
105
|
-
buckets.map(b => ({ ...b, experimentId: experiment.id })),
|
|
106
|
-
),
|
|
78
|
+
...created,
|
|
79
|
+
buckets: createdbuckets,
|
|
107
80
|
}
|
|
108
81
|
}
|
|
109
82
|
|
|
@@ -119,22 +92,56 @@ export class Abba {
|
|
|
119
92
|
validateTotalBucketRatio(buckets)
|
|
120
93
|
}
|
|
121
94
|
|
|
122
|
-
await this.experimentDao.save(experiment)
|
|
95
|
+
const updatedExperiment = await this.experimentDao.save(experiment)
|
|
96
|
+
const updatedBuckets = await this.bucketDao.saveBatch(
|
|
97
|
+
buckets.map(b => ({ ...b, experimentId: experiment.id })),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions)
|
|
123
101
|
|
|
124
102
|
return {
|
|
125
|
-
...
|
|
126
|
-
buckets:
|
|
127
|
-
buckets.map(b => ({ ...b, experimentId: experiment.id })),
|
|
128
|
-
),
|
|
103
|
+
...updatedExperiment,
|
|
104
|
+
buckets: updatedBuckets,
|
|
129
105
|
}
|
|
130
106
|
}
|
|
131
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Ensures that mutual exclusions are maintained
|
|
110
|
+
*/
|
|
111
|
+
private async updateExclusions(experimentId: number, updatedExclusions: number[]): Promise<void> {
|
|
112
|
+
const experiments = await this.experimentDao.getAll()
|
|
113
|
+
|
|
114
|
+
const requiresUpdating: Experiment[] = []
|
|
115
|
+
experiments.map(experiment => {
|
|
116
|
+
// Make sure it's mutual
|
|
117
|
+
if (
|
|
118
|
+
updatedExclusions.includes(experiment.id) &&
|
|
119
|
+
!experiment.exclusions.includes(experimentId)
|
|
120
|
+
) {
|
|
121
|
+
experiment.exclusions.push(experimentId)
|
|
122
|
+
requiresUpdating.push(experiment)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Make sure it's mutual
|
|
126
|
+
if (
|
|
127
|
+
!updatedExclusions.includes(experiment.id) &&
|
|
128
|
+
experiment.exclusions.includes(experimentId)
|
|
129
|
+
) {
|
|
130
|
+
experiment.exclusions = experiment.exclusions.filter(id => id !== experimentId)
|
|
131
|
+
requiresUpdating.push(experiment)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await this.experimentDao.saveBatch(requiresUpdating)
|
|
136
|
+
}
|
|
137
|
+
|
|
132
138
|
/**
|
|
133
139
|
* Delete an experiment. Removes all user assignments and buckets.
|
|
134
140
|
* Cold method.
|
|
135
141
|
*/
|
|
136
|
-
async deleteExperiment(
|
|
137
|
-
await this.experimentDao.deleteById(
|
|
142
|
+
async deleteExperiment(experimentId: number): Promise<void> {
|
|
143
|
+
await this.experimentDao.deleteById(experimentId)
|
|
144
|
+
await this.updateExclusions(experimentId, [])
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
/**
|
|
@@ -152,8 +159,9 @@ export class Abba {
|
|
|
152
159
|
existingOnly: boolean,
|
|
153
160
|
segmentationData?: SegmentationData,
|
|
154
161
|
): Promise<GeneratedUserAssignment | null> {
|
|
155
|
-
const
|
|
162
|
+
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
156
163
|
|
|
164
|
+
const existing = existingAssignments.find(a => a.experimentId === experimentId)
|
|
157
165
|
if (existing) {
|
|
158
166
|
let bucketKey = null
|
|
159
167
|
if (existing.bucketId) {
|
|
@@ -165,25 +173,23 @@ export class Abba {
|
|
|
165
173
|
|
|
166
174
|
if (existingOnly) return null
|
|
167
175
|
|
|
168
|
-
const
|
|
169
|
-
|
|
176
|
+
const experiments = await this.getAllExperiments()
|
|
177
|
+
const experiment = experiments.find(e => e.id === experimentId)
|
|
178
|
+
_assert(experiment, `Experiment does not exist: ${experimentId}`)
|
|
170
179
|
|
|
171
|
-
|
|
180
|
+
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
181
|
+
if (!canGenerateNewAssignments(experiment, exclusionSet)) return null
|
|
172
182
|
|
|
173
|
-
|
|
183
|
+
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
174
184
|
|
|
175
|
-
const assignment =
|
|
176
|
-
{ ...experiment, buckets },
|
|
177
|
-
userId,
|
|
178
|
-
segmentationData,
|
|
179
|
-
)
|
|
185
|
+
const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
|
|
180
186
|
if (!assignment) return null
|
|
181
187
|
|
|
182
188
|
const saved = await this.userAssignmentDao.save(assignment)
|
|
183
189
|
|
|
184
190
|
return {
|
|
185
191
|
...saved,
|
|
186
|
-
bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
|
|
192
|
+
bucketKey: experiment.buckets.find(b => b.id === saved.bucketId)?.key || null,
|
|
187
193
|
}
|
|
188
194
|
}
|
|
189
195
|
|
|
@@ -206,21 +212,31 @@ export class Abba {
|
|
|
206
212
|
segmentationData: SegmentationData,
|
|
207
213
|
existingOnly = false,
|
|
208
214
|
): Promise<GeneratedUserAssignment[]> {
|
|
209
|
-
const experiments = await this.
|
|
215
|
+
const experiments = await this.getAllExperiments()
|
|
216
|
+
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
217
|
+
const exclusionSet = getUserExclusionSet(experiments, existingAssignments)
|
|
210
218
|
|
|
211
219
|
const assignments: GeneratedUserAssignment[] = []
|
|
212
220
|
const newAssignments: UserAssignment[] = []
|
|
213
|
-
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
214
221
|
|
|
215
|
-
|
|
222
|
+
// Shuffling means that randomisation occurs in the mutual exclusion
|
|
223
|
+
// 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
|
|
224
|
+
// This is simmpler than trying to resolve after assignments have already been determined
|
|
225
|
+
const availableExperiments = _shuffle(
|
|
226
|
+
experiments.filter(
|
|
227
|
+
e => e.status === AssignmentStatus.Active || e.status === AssignmentStatus.Paused,
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for (const experiment of availableExperiments) {
|
|
216
232
|
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
|
|
217
233
|
if (existing) {
|
|
218
234
|
assignments.push({
|
|
219
235
|
...existing,
|
|
220
236
|
bucketKey: experiment.buckets.find(b => b.id === existing.bucketId)?.key || null,
|
|
221
237
|
})
|
|
222
|
-
} else if (!existingOnly && canGenerateNewAssignments(experiment)) {
|
|
223
|
-
const assignment =
|
|
238
|
+
} else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
239
|
+
const assignment = generateUserAssignmentData(experiment, userId, segmentationData)
|
|
224
240
|
if (assignment) {
|
|
225
241
|
const created = this.userAssignmentDao.create(assignment)
|
|
226
242
|
newAssignments.push(created)
|
|
@@ -228,6 +244,8 @@ export class Abba {
|
|
|
228
244
|
...created,
|
|
229
245
|
bucketKey: experiment.buckets.find(b => b.id === created.bucketId)?.key || null,
|
|
230
246
|
})
|
|
247
|
+
// Prevent future exclusion clashes
|
|
248
|
+
experiment.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
231
249
|
}
|
|
232
250
|
}
|
|
233
251
|
}
|
|
@@ -259,41 +277,4 @@ export class Abba {
|
|
|
259
277
|
|
|
260
278
|
return statistics
|
|
261
279
|
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Generate a new assignment for a given user.
|
|
265
|
-
* Doesn't save it.
|
|
266
|
-
*/
|
|
267
|
-
private generateUserAssignmentData(
|
|
268
|
-
experiment: ExperimentWithBuckets,
|
|
269
|
-
userId: string,
|
|
270
|
-
segmentationData: SegmentationData,
|
|
271
|
-
): UserAssignment | null {
|
|
272
|
-
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
|
|
273
|
-
if (!segmentationMatch) return null
|
|
274
|
-
|
|
275
|
-
const bucket = determineAssignment(experiment.sampling, experiment.buckets)
|
|
276
|
-
|
|
277
|
-
return {
|
|
278
|
-
userId,
|
|
279
|
-
experimentId: experiment.id,
|
|
280
|
-
bucketId: bucket?.id || null,
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Queries to retrieve an existing user assignment for a given experiment
|
|
286
|
-
*/
|
|
287
|
-
private async getExistingUserAssignment(
|
|
288
|
-
experimentId: number,
|
|
289
|
-
userId: string,
|
|
290
|
-
): Promise<Saved<UserAssignment> | null> {
|
|
291
|
-
const [assignment] = await this.userAssignmentDao
|
|
292
|
-
.query()
|
|
293
|
-
.filterEq('userId', userId)
|
|
294
|
-
.filterEq('experimentId', experimentId)
|
|
295
|
-
.runQuery()
|
|
296
|
-
|
|
297
|
-
return assignment || null
|
|
298
|
-
}
|
|
299
280
|
}
|
|
@@ -4,6 +4,7 @@ import { BaseExperiment, Experiment } from '../types'
|
|
|
4
4
|
|
|
5
5
|
type ExperimentDBM = Saved<BaseExperiment> & {
|
|
6
6
|
rules: string | null
|
|
7
|
+
exclusions: string | null
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export class ExperimentDao extends CommonDao<Experiment, ExperimentDBM> {}
|
|
@@ -16,7 +17,15 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
|
|
|
16
17
|
idType: 'number',
|
|
17
18
|
assignGeneratedIds: true,
|
|
18
19
|
hooks: {
|
|
19
|
-
beforeBMToDBM: bm => ({
|
|
20
|
-
|
|
20
|
+
beforeBMToDBM: bm => ({
|
|
21
|
+
...bm,
|
|
22
|
+
rules: bm.rules.length ? JSON.stringify(bm.rules) : null,
|
|
23
|
+
exclusions: bm.exclusions.length ? JSON.stringify(bm.exclusions) : null,
|
|
24
|
+
}),
|
|
25
|
+
beforeDBMToBM: dbm => ({
|
|
26
|
+
...dbm,
|
|
27
|
+
rules: (dbm.rules && JSON.parse(dbm.rules)) || [],
|
|
28
|
+
exclusions: (dbm.exclusions && JSON.parse(dbm.exclusions)) || [],
|
|
29
|
+
}),
|
|
21
30
|
},
|
|
22
31
|
})
|
package/src/migrations/init.sql
CHANGED
package/src/types.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import { CommonDB } from '@naturalcycles/db-lib'
|
|
2
|
-
import { BaseDBEntity, Saved } from '@naturalcycles/js-lib'
|
|
2
|
+
import { AnyObject, BaseDBEntity, Saved } from '@naturalcycles/js-lib'
|
|
3
3
|
|
|
4
4
|
export interface AbbaConfig {
|
|
5
5
|
db: CommonDB
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Determines for how long to cache "hot" requests.
|
|
9
|
-
* Caches in memory.
|
|
10
|
-
* Default is 10.
|
|
11
|
-
* Set to 0 to disable cache (every request will hit the DB).
|
|
12
|
-
*/
|
|
13
|
-
// cacheMinutes?: number
|
|
14
6
|
}
|
|
15
7
|
|
|
16
8
|
export type BaseExperiment = BaseDBEntity<number> & {
|
|
@@ -24,6 +16,7 @@ export type BaseExperiment = BaseDBEntity<number> & {
|
|
|
24
16
|
|
|
25
17
|
export type Experiment = BaseExperiment & {
|
|
26
18
|
rules: SegmentationRule[]
|
|
19
|
+
exclusions: number[]
|
|
27
20
|
}
|
|
28
21
|
|
|
29
22
|
export type ExperimentWithBuckets = Saved<Experiment> & {
|
|
@@ -36,11 +29,7 @@ export interface BucketInput {
|
|
|
36
29
|
ratio: number
|
|
37
30
|
}
|
|
38
31
|
|
|
39
|
-
export type Bucket = BaseDBEntity<number> &
|
|
40
|
-
experimentId: number
|
|
41
|
-
key: string
|
|
42
|
-
ratio: number
|
|
43
|
-
}
|
|
32
|
+
export type Bucket = BaseDBEntity<number> & BucketInput
|
|
44
33
|
|
|
45
34
|
export type UserAssignment = BaseDBEntity<number> & {
|
|
46
35
|
userId: string
|
|
@@ -52,7 +41,7 @@ export type GeneratedUserAssignment = Saved<UserAssignment> & {
|
|
|
52
41
|
bucketKey: string | null
|
|
53
42
|
}
|
|
54
43
|
|
|
55
|
-
export type SegmentationData =
|
|
44
|
+
export type SegmentationData = AnyObject
|
|
56
45
|
|
|
57
46
|
export enum AssignmentStatus {
|
|
58
47
|
/**
|
|
@@ -81,3 +70,5 @@ export interface AssignmentStatistics {
|
|
|
81
70
|
[id: string]: number
|
|
82
71
|
}
|
|
83
72
|
}
|
|
73
|
+
|
|
74
|
+
export type ExclusionSet = Set<number>
|
package/src/util.ts
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
import { localDate, Saved } from '@naturalcycles/js-lib'
|
|
2
2
|
import { satisfies } from 'semver'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AssignmentStatus,
|
|
5
|
+
Bucket,
|
|
6
|
+
ExclusionSet,
|
|
7
|
+
Experiment,
|
|
8
|
+
ExperimentWithBuckets,
|
|
9
|
+
SegmentationData,
|
|
10
|
+
SegmentationRule,
|
|
11
|
+
UserAssignment,
|
|
12
|
+
} from './types'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a new assignment for a given user.
|
|
16
|
+
* Doesn't save it.
|
|
17
|
+
*/
|
|
18
|
+
export const generateUserAssignmentData = (
|
|
19
|
+
experiment: ExperimentWithBuckets,
|
|
20
|
+
userId: string,
|
|
21
|
+
segmentationData: SegmentationData,
|
|
22
|
+
): UserAssignment | null => {
|
|
23
|
+
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
|
|
24
|
+
if (!segmentationMatch) return null
|
|
25
|
+
|
|
26
|
+
const bucket = determineAssignment(experiment.sampling, experiment.buckets)
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
userId,
|
|
30
|
+
experimentId: experiment.id,
|
|
31
|
+
bucketId: bucket?.id || null,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
4
34
|
|
|
5
35
|
/**
|
|
6
36
|
* Generate a random number between 0 and 100
|
|
@@ -99,8 +129,12 @@ export const validateSegmentationRule = (
|
|
|
99
129
|
/**
|
|
100
130
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
101
131
|
*/
|
|
102
|
-
export const canGenerateNewAssignments = (
|
|
132
|
+
export const canGenerateNewAssignments = (
|
|
133
|
+
experiment: Experiment,
|
|
134
|
+
exclusionSet: ExclusionSet,
|
|
135
|
+
): boolean => {
|
|
103
136
|
return (
|
|
137
|
+
!exclusionSet.has(experiment.id) &&
|
|
104
138
|
experiment.status === AssignmentStatus.Active &&
|
|
105
139
|
localDate().isBetween(
|
|
106
140
|
localDate(experiment.startDateIncl.toISOString()),
|
|
@@ -109,3 +143,19 @@ export const canGenerateNewAssignments = (experiment: Experiment): boolean => {
|
|
|
109
143
|
)
|
|
110
144
|
)
|
|
111
145
|
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
149
|
+
* based on a combination of existing assignments and mutual exclusion configuration
|
|
150
|
+
*/
|
|
151
|
+
export const getUserExclusionSet = (
|
|
152
|
+
experiments: Experiment[],
|
|
153
|
+
existingAssignments: UserAssignment[],
|
|
154
|
+
): ExclusionSet => {
|
|
155
|
+
const exclusionSet: ExclusionSet = new Set()
|
|
156
|
+
existingAssignments.forEach(assignment => {
|
|
157
|
+
const experiment = experiments.find(e => e.id === assignment.experimentId)
|
|
158
|
+
experiment?.exclusions.forEach(experimentId => exclusionSet.add(experimentId))
|
|
159
|
+
})
|
|
160
|
+
return exclusionSet
|
|
161
|
+
}
|