@naturalcycles/abba 1.9.0 → 1.10.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 +37 -50
- package/dist/abba.js +112 -87
- package/dist/dao/experiment.dao.js +1 -1
- package/dist/types.d.ts +4 -0
- package/dist/util.d.ts +2 -18
- package/dist/util.js +1 -17
- package/package.json +4 -3
- package/readme.md +2 -2
- package/src/abba.ts +142 -103
- package/src/dao/experiment.dao.ts +1 -1
- package/src/types.ts +13 -0
- package/src/util.ts +3 -19
package/dist/abba.d.ts
CHANGED
|
@@ -1,88 +1,75 @@
|
|
|
1
1
|
import { Saved } from '@naturalcycles/js-lib';
|
|
2
|
-
import { AbbaConfig, Bucket, Experiment, ExperimentWithBuckets, UserAssignment } from './types';
|
|
2
|
+
import { AbbaConfig, Bucket, BucketInput, Experiment, ExperimentWithBuckets, GeneratedUserAssignment, UserAssignment } from './types';
|
|
3
3
|
import { SegmentationData, AssignmentStatistics } from '.';
|
|
4
4
|
export declare class Abba {
|
|
5
|
+
cfg: AbbaConfig;
|
|
6
|
+
constructor(cfg: AbbaConfig);
|
|
5
7
|
private experimentDao;
|
|
6
8
|
private bucketDao;
|
|
7
9
|
private userAssignmentDao;
|
|
8
|
-
constructor({ db }: AbbaConfig);
|
|
9
10
|
/**
|
|
10
|
-
* Returns all experiments
|
|
11
|
+
* Returns all (active and inactive) experiments.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
+
* Cold method, not cached.
|
|
13
14
|
*/
|
|
14
|
-
getAllExperiments(
|
|
15
|
+
getAllExperiments(): Promise<ExperimentWithBuckets[]>;
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* @param buckets
|
|
20
|
-
* @returns
|
|
17
|
+
* Returns only active experiments.
|
|
18
|
+
* Hot method.
|
|
19
|
+
* Cached in-memory for N minutes (currently 10).
|
|
21
20
|
*/
|
|
22
|
-
|
|
21
|
+
getActiveExperiments(): Promise<ExperimentWithBuckets[]>;
|
|
23
22
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @param id
|
|
27
|
-
* @param experiment
|
|
28
|
-
* @param rules
|
|
29
|
-
* @param buckets
|
|
30
|
-
* @returns
|
|
23
|
+
* Creates a new experiment.
|
|
24
|
+
* Cold method.
|
|
31
25
|
*/
|
|
32
|
-
|
|
26
|
+
createExperiment(experiment: Experiment, buckets: BucketInput[]): Promise<ExperimentWithBuckets>;
|
|
33
27
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
28
|
+
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
29
|
+
* Cold method.
|
|
30
|
+
*/
|
|
31
|
+
saveExperiment(experiment: Experiment & {
|
|
32
|
+
id: number;
|
|
33
|
+
}, buckets: Bucket[]): Promise<ExperimentWithBuckets>;
|
|
34
|
+
/**
|
|
35
|
+
* Delete an experiment. Removes all user assignments and buckets.
|
|
36
|
+
* Cold method.
|
|
37
37
|
*/
|
|
38
38
|
deleteExperiment(id: number): Promise<void>;
|
|
39
39
|
/**
|
|
40
|
-
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
40
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
41
|
+
* Cold method.
|
|
41
42
|
*
|
|
42
43
|
* @param experimentId
|
|
43
44
|
* @param userId
|
|
44
45
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
45
46
|
* @param segmentationData Required if existingOnly is false
|
|
46
|
-
* @returns
|
|
47
47
|
*/
|
|
48
|
-
getUserAssignment(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<
|
|
48
|
+
getUserAssignment(experimentId: number, userId: string, existingOnly: boolean, segmentationData?: SegmentationData): Promise<GeneratedUserAssignment | null>;
|
|
49
49
|
/**
|
|
50
|
-
* Get all existing user assignments
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* @returns
|
|
50
|
+
* Get all existing user assignments.
|
|
51
|
+
* Hot method.
|
|
52
|
+
* Not cached, because Assignments are fast-changing.
|
|
54
53
|
*/
|
|
55
54
|
getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]>;
|
|
56
55
|
/**
|
|
57
|
-
* Generate user assignments for all active experiments.
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* @param segmentationData
|
|
61
|
-
* @returns
|
|
56
|
+
* Generate user assignments for all active experiments.
|
|
57
|
+
* Will return any existing and attempt to generate any new assignments.
|
|
58
|
+
* Hot method.
|
|
62
59
|
*/
|
|
63
|
-
generateUserAssignments(userId: string, segmentationData: SegmentationData): Promise<
|
|
60
|
+
generateUserAssignments(userId: string, segmentationData: SegmentationData): Promise<GeneratedUserAssignment[]>;
|
|
64
61
|
/**
|
|
65
|
-
* Get assignment statistics for an experiment
|
|
66
|
-
*
|
|
67
|
-
* @param experimentId
|
|
68
|
-
* @returns
|
|
62
|
+
* Get assignment statistics for an experiment.
|
|
63
|
+
* Cold method.
|
|
69
64
|
*/
|
|
70
65
|
getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics>;
|
|
71
66
|
/**
|
|
72
|
-
* Generate a new assignment for a given user
|
|
73
|
-
*
|
|
74
|
-
* @param experimentId
|
|
75
|
-
* @param userId
|
|
76
|
-
* @param segmentationData
|
|
77
|
-
* @returns
|
|
67
|
+
* Generate a new assignment for a given user.
|
|
68
|
+
* Doesn't save it.
|
|
78
69
|
*/
|
|
79
|
-
private
|
|
70
|
+
private generateUserAssignmentData;
|
|
80
71
|
/**
|
|
81
72
|
* Queries to retrieve an existing user assignment for a given experiment
|
|
82
|
-
*
|
|
83
|
-
* @param experimentId
|
|
84
|
-
* @param userId
|
|
85
|
-
* @returns
|
|
86
73
|
*/
|
|
87
74
|
private getExistingUserAssignment;
|
|
88
75
|
}
|
package/dist/abba.js
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Abba = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
4
5
|
const js_lib_1 = require("@naturalcycles/js-lib");
|
|
6
|
+
const nodejs_lib_1 = require("@naturalcycles/nodejs-lib");
|
|
5
7
|
const types_1 = require("./types");
|
|
6
8
|
const util_1 = require("./util");
|
|
7
9
|
const experiment_dao_1 = require("./dao/experiment.dao");
|
|
8
10
|
const userAssignment_dao_1 = require("./dao/userAssignment.dao");
|
|
9
11
|
const bucket_dao_1 = require("./dao/bucket.dao");
|
|
12
|
+
const CACHE_TTL = 600000; // 10 minutes
|
|
10
13
|
class Abba {
|
|
11
|
-
constructor(
|
|
14
|
+
constructor(cfg) {
|
|
15
|
+
this.cfg = cfg;
|
|
16
|
+
const { db } = cfg;
|
|
12
17
|
this.experimentDao = (0, experiment_dao_1.experimentDao)(db);
|
|
13
18
|
this.bucketDao = (0, bucket_dao_1.bucketDao)(db);
|
|
14
19
|
this.userAssignmentDao = (0, userAssignment_dao_1.userAssignmentDao)(db);
|
|
15
20
|
}
|
|
16
|
-
// TODO: Cache me
|
|
17
21
|
/**
|
|
18
|
-
* Returns all experiments
|
|
22
|
+
* Returns all (active and inactive) experiments.
|
|
19
23
|
*
|
|
20
|
-
*
|
|
24
|
+
* Cold method, not cached.
|
|
21
25
|
*/
|
|
22
|
-
async getAllExperiments(
|
|
23
|
-
const
|
|
24
|
-
if (excludeInactive) {
|
|
25
|
-
query.filter('status', '!=', types_1.AssignmentStatus.Inactive);
|
|
26
|
-
}
|
|
27
|
-
const experiments = await this.experimentDao.runQuery(query);
|
|
26
|
+
async getAllExperiments() {
|
|
27
|
+
const experiments = await this.experimentDao.query().runQuery();
|
|
28
28
|
const buckets = await this.bucketDao
|
|
29
29
|
.query()
|
|
30
30
|
.filter('experimentId', 'in', experiments.map(e => e.id))
|
|
@@ -35,115 +35,139 @@ class Abba {
|
|
|
35
35
|
}));
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
* Returns only active experiments.
|
|
39
|
+
* Hot method.
|
|
40
|
+
* Cached in-memory for N minutes (currently 10).
|
|
41
|
+
*/
|
|
42
|
+
async getActiveExperiments() {
|
|
43
|
+
const experiments = await this.experimentDao
|
|
44
|
+
.query()
|
|
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();
|
|
51
|
+
return experiments.map(experiment => ({
|
|
52
|
+
...experiment,
|
|
53
|
+
buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Creates a new experiment.
|
|
58
|
+
* Cold method.
|
|
43
59
|
*/
|
|
44
60
|
async createExperiment(experiment, buckets) {
|
|
45
61
|
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
46
62
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
47
63
|
}
|
|
48
|
-
|
|
64
|
+
await this.experimentDao.save(experiment);
|
|
49
65
|
return {
|
|
50
|
-
...
|
|
51
|
-
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId:
|
|
66
|
+
...experiment,
|
|
67
|
+
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
|
|
52
68
|
};
|
|
53
69
|
}
|
|
54
70
|
/**
|
|
55
|
-
* Update experiment information, will also validate the buckets ratio if experiment.active is true
|
|
56
|
-
*
|
|
57
|
-
* @param id
|
|
58
|
-
* @param experiment
|
|
59
|
-
* @param rules
|
|
60
|
-
* @param buckets
|
|
61
|
-
* @returns
|
|
71
|
+
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
72
|
+
* Cold method.
|
|
62
73
|
*/
|
|
63
|
-
async saveExperiment(
|
|
74
|
+
async saveExperiment(experiment, buckets) {
|
|
64
75
|
if (experiment.status === types_1.AssignmentStatus.Active) {
|
|
65
76
|
(0, util_1.validateTotalBucketRatio)(buckets);
|
|
66
77
|
}
|
|
67
|
-
|
|
68
|
-
...experiment,
|
|
69
|
-
id,
|
|
70
|
-
});
|
|
78
|
+
await this.experimentDao.save(experiment);
|
|
71
79
|
return {
|
|
72
|
-
...
|
|
73
|
-
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: id }))),
|
|
80
|
+
...experiment,
|
|
81
|
+
buckets: await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: experiment.id }))),
|
|
74
82
|
};
|
|
75
83
|
}
|
|
76
84
|
/**
|
|
77
|
-
* Delete an experiment. Removes all user assignments and buckets
|
|
78
|
-
*
|
|
79
|
-
* @param id
|
|
85
|
+
* Delete an experiment. Removes all user assignments and buckets.
|
|
86
|
+
* Cold method.
|
|
80
87
|
*/
|
|
81
88
|
async deleteExperiment(id) {
|
|
82
89
|
await this.experimentDao.deleteById(id);
|
|
83
90
|
}
|
|
84
91
|
/**
|
|
85
|
-
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
92
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
93
|
+
* Cold method.
|
|
86
94
|
*
|
|
87
95
|
* @param experimentId
|
|
88
96
|
* @param userId
|
|
89
97
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
90
98
|
* @param segmentationData Required if existingOnly is false
|
|
91
|
-
* @returns
|
|
92
99
|
*/
|
|
93
100
|
async getUserAssignment(experimentId, userId, existingOnly, segmentationData) {
|
|
101
|
+
const existing = await this.getExistingUserAssignment(experimentId, userId);
|
|
102
|
+
if (existing) {
|
|
103
|
+
const experiment = await this.experimentDao.requireById(experimentId);
|
|
104
|
+
const bucket = await this.bucketDao.getById(existing.bucketId || undefined);
|
|
105
|
+
return {
|
|
106
|
+
...existing,
|
|
107
|
+
experimentName: experiment.name,
|
|
108
|
+
bucketKey: bucket?.key || null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (existingOnly)
|
|
112
|
+
return null;
|
|
94
113
|
const experiment = await this.experimentDao.requireById(experimentId);
|
|
95
|
-
if (
|
|
96
|
-
|
|
114
|
+
if (experiment.status !== types_1.AssignmentStatus.Active)
|
|
115
|
+
return null;
|
|
116
|
+
(0, js_lib_1._assert)(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
97
117
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
return existing;
|
|
101
|
-
if (experiment.status !== types_1.AssignmentStatus.Active || existingOnly)
|
|
118
|
+
const assignment = this.generateUserAssignmentData({ ...experiment, buckets }, userId, segmentationData);
|
|
119
|
+
if (!assignment)
|
|
102
120
|
return null;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
const saved = await this.userAssignmentDao.save(assignment);
|
|
122
|
+
return {
|
|
123
|
+
...saved,
|
|
124
|
+
experimentName: experiment.name,
|
|
125
|
+
bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
|
|
126
|
+
};
|
|
106
127
|
}
|
|
107
128
|
/**
|
|
108
|
-
* Get all existing user assignments
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* @returns
|
|
129
|
+
* Get all existing user assignments.
|
|
130
|
+
* Hot method.
|
|
131
|
+
* Not cached, because Assignments are fast-changing.
|
|
112
132
|
*/
|
|
113
133
|
async getAllExistingUserAssignments(userId) {
|
|
114
134
|
return await this.userAssignmentDao.getBy('userId', userId);
|
|
115
135
|
}
|
|
116
136
|
/**
|
|
117
|
-
* Generate user assignments for all active experiments.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* @param segmentationData
|
|
121
|
-
* @returns
|
|
137
|
+
* Generate user assignments for all active experiments.
|
|
138
|
+
* Will return any existing and attempt to generate any new assignments.
|
|
139
|
+
* Hot method.
|
|
122
140
|
*/
|
|
123
141
|
async generateUserAssignments(userId, segmentationData) {
|
|
124
|
-
const experiments = await this.
|
|
142
|
+
const experiments = await this.getActiveExperiments(); // cached
|
|
125
143
|
const existingAssignments = await this.getAllExistingUserAssignments(userId);
|
|
144
|
+
const newAssignments = [];
|
|
145
|
+
for (const experiment of experiments) {
|
|
146
|
+
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
|
|
147
|
+
if (!existing) {
|
|
148
|
+
const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData);
|
|
149
|
+
if (assignment) {
|
|
150
|
+
newAssignments.push(assignment);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
existingAssignments.push(...(await this.userAssignmentDao.saveBatch(newAssignments)));
|
|
126
155
|
const assignments = [];
|
|
127
|
-
const generatedAssignments = [];
|
|
128
156
|
for (const experiment of experiments) {
|
|
129
157
|
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
|
|
130
158
|
if (existing) {
|
|
131
|
-
assignments.push(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
159
|
+
assignments.push({
|
|
160
|
+
...existing,
|
|
161
|
+
experimentName: experiment.name,
|
|
162
|
+
bucketKey: experiment.buckets.find(i => i.id === existing.bucketId)?.key || null,
|
|
163
|
+
});
|
|
136
164
|
}
|
|
137
165
|
}
|
|
138
|
-
|
|
139
|
-
const filtered = generated.filter((ua) => ua !== null);
|
|
140
|
-
return [...assignments, ...filtered];
|
|
166
|
+
return assignments;
|
|
141
167
|
}
|
|
142
168
|
/**
|
|
143
|
-
* Get assignment statistics for an experiment
|
|
144
|
-
*
|
|
145
|
-
* @param experimentId
|
|
146
|
-
* @returns
|
|
169
|
+
* Get assignment statistics for an experiment.
|
|
170
|
+
* Cold method.
|
|
147
171
|
*/
|
|
148
172
|
async getExperimentAssignmentStatistics(experimentId) {
|
|
149
173
|
const statistics = {
|
|
@@ -155,40 +179,41 @@ class Abba {
|
|
|
155
179
|
};
|
|
156
180
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
157
181
|
await (0, js_lib_1.pMap)(buckets, async (bucket) => {
|
|
158
|
-
|
|
159
|
-
|
|
182
|
+
statistics.buckets[bucket.id] = await this.userAssignmentDao
|
|
183
|
+
.query()
|
|
184
|
+
.filterEq('bucketId', bucket.id)
|
|
185
|
+
.runQueryCount();
|
|
160
186
|
});
|
|
161
187
|
return statistics;
|
|
162
188
|
}
|
|
163
189
|
/**
|
|
164
|
-
* Generate a new assignment for a given user
|
|
165
|
-
*
|
|
166
|
-
* @param experimentId
|
|
167
|
-
* @param userId
|
|
168
|
-
* @param segmentationData
|
|
169
|
-
* @returns
|
|
190
|
+
* Generate a new assignment for a given user.
|
|
191
|
+
* Doesn't save it.
|
|
170
192
|
*/
|
|
171
|
-
|
|
193
|
+
generateUserAssignmentData(experiment, userId, segmentationData) {
|
|
172
194
|
const segmentationMatch = (0, util_1.validateSegmentationRules)(experiment.rules, segmentationData);
|
|
173
195
|
if (!segmentationMatch)
|
|
174
196
|
return null;
|
|
175
|
-
|
|
197
|
+
const bucket = (0, util_1.determineAssignment)(experiment.sampling, experiment.buckets);
|
|
198
|
+
return {
|
|
176
199
|
userId,
|
|
177
200
|
experimentId: experiment.id,
|
|
178
|
-
bucketId:
|
|
179
|
-
}
|
|
201
|
+
bucketId: bucket?.id || null,
|
|
202
|
+
};
|
|
180
203
|
}
|
|
181
204
|
/**
|
|
182
205
|
* Queries to retrieve an existing user assignment for a given experiment
|
|
183
|
-
*
|
|
184
|
-
* @param experimentId
|
|
185
|
-
* @param userId
|
|
186
|
-
* @returns
|
|
187
206
|
*/
|
|
188
207
|
async getExistingUserAssignment(experimentId, userId) {
|
|
189
|
-
const
|
|
190
|
-
|
|
208
|
+
const [assignment] = await this.userAssignmentDao
|
|
209
|
+
.query()
|
|
210
|
+
.filterEq('userId', userId)
|
|
211
|
+
.filterEq('experimentId', experimentId)
|
|
212
|
+
.runQuery();
|
|
191
213
|
return assignment || null;
|
|
192
214
|
}
|
|
193
215
|
}
|
|
216
|
+
tslib_1.__decorate([
|
|
217
|
+
(0, js_lib_1._AsyncMemo)({ cacheFactory: () => new nodejs_lib_1.LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
218
|
+
], Abba.prototype, "getActiveExperiments", null);
|
|
194
219
|
exports.Abba = Abba;
|
|
@@ -13,7 +13,7 @@ const experimentDao = (db) => new ExperimentDao({
|
|
|
13
13
|
assignGeneratedIds: true,
|
|
14
14
|
hooks: {
|
|
15
15
|
beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
|
|
16
|
-
beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
|
|
16
|
+
beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
|
|
17
17
|
},
|
|
18
18
|
});
|
|
19
19
|
exports.experimentDao = experimentDao;
|
package/dist/types.d.ts
CHANGED
|
@@ -30,6 +30,10 @@ export declare type UserAssignment = BaseDBEntity<number> & {
|
|
|
30
30
|
experimentId: number;
|
|
31
31
|
bucketId: number | null;
|
|
32
32
|
};
|
|
33
|
+
export declare type GeneratedUserAssignment = Saved<UserAssignment> & {
|
|
34
|
+
experimentName: string;
|
|
35
|
+
bucketKey: string | null;
|
|
36
|
+
};
|
|
33
37
|
export declare type SegmentationData = Record<string, string | boolean | number>;
|
|
34
38
|
export declare enum AssignmentStatus {
|
|
35
39
|
Active = 1,
|
package/dist/util.d.ts
CHANGED
|
@@ -2,30 +2,18 @@ import { Saved } from '@naturalcycles/js-lib';
|
|
|
2
2
|
import { Bucket, SegmentationData, SegmentationRule } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Generate a random number between 0 and 100
|
|
5
|
-
*
|
|
6
|
-
* @returns
|
|
7
5
|
*/
|
|
8
6
|
export declare const rollDie: () => number;
|
|
9
7
|
/**
|
|
10
8
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
11
|
-
*
|
|
12
|
-
* @param sampling
|
|
13
|
-
* @param buckets
|
|
14
|
-
* @returns
|
|
15
9
|
*/
|
|
16
|
-
export declare const determineAssignment: (sampling: number, buckets: Saved<Bucket>[]) =>
|
|
10
|
+
export declare const determineAssignment: (sampling: number, buckets: Saved<Bucket>[]) => Bucket | null;
|
|
17
11
|
/**
|
|
18
12
|
* Determines which bucket a user assignment will recieve
|
|
19
|
-
*
|
|
20
|
-
* @param buckets
|
|
21
|
-
* @returns
|
|
22
13
|
*/
|
|
23
|
-
export declare const determineBucket: (buckets: Saved<Bucket>[]) =>
|
|
14
|
+
export declare const determineBucket: (buckets: Saved<Bucket>[]) => Bucket;
|
|
24
15
|
/**
|
|
25
16
|
* Validate the total ratio of the buckets equals 100
|
|
26
|
-
*
|
|
27
|
-
* @param buckets
|
|
28
|
-
* @returns
|
|
29
17
|
*/
|
|
30
18
|
export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
|
|
31
19
|
/**
|
|
@@ -38,9 +26,5 @@ export declare const validateTotalBucketRatio: (buckets: Bucket[]) => void;
|
|
|
38
26
|
export declare const validateSegmentationRules: (rules: SegmentationRule[], segmentationData: SegmentationData) => boolean;
|
|
39
27
|
/**
|
|
40
28
|
* Validate a users segmentation data against a single rule
|
|
41
|
-
*
|
|
42
|
-
* @param rule
|
|
43
|
-
* @param segmentationData
|
|
44
|
-
* @returns
|
|
45
29
|
*/
|
|
46
30
|
export declare const validateSegmentationRule: (rule: SegmentationRule, data: SegmentationData) => boolean;
|
package/dist/util.js
CHANGED
|
@@ -4,8 +4,6 @@ exports.validateSegmentationRule = exports.validateSegmentationRules = exports.v
|
|
|
4
4
|
const semver_1 = require("semver");
|
|
5
5
|
/**
|
|
6
6
|
* Generate a random number between 0 and 100
|
|
7
|
-
*
|
|
8
|
-
* @returns
|
|
9
7
|
*/
|
|
10
8
|
const rollDie = () => {
|
|
11
9
|
return Math.random() * 100;
|
|
@@ -13,10 +11,6 @@ const rollDie = () => {
|
|
|
13
11
|
exports.rollDie = rollDie;
|
|
14
12
|
/**
|
|
15
13
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
16
|
-
*
|
|
17
|
-
* @param sampling
|
|
18
|
-
* @param buckets
|
|
19
|
-
* @returns
|
|
20
14
|
*/
|
|
21
15
|
const determineAssignment = (sampling, buckets) => {
|
|
22
16
|
// Should this person be considered for the experiment?
|
|
@@ -29,9 +23,6 @@ const determineAssignment = (sampling, buckets) => {
|
|
|
29
23
|
exports.determineAssignment = determineAssignment;
|
|
30
24
|
/**
|
|
31
25
|
* Determines which bucket a user assignment will recieve
|
|
32
|
-
*
|
|
33
|
-
* @param buckets
|
|
34
|
-
* @returns
|
|
35
26
|
*/
|
|
36
27
|
const determineBucket = (buckets) => {
|
|
37
28
|
const bucketRoll = (0, exports.rollDie)();
|
|
@@ -50,14 +41,11 @@ const determineBucket = (buckets) => {
|
|
|
50
41
|
if (!bucket) {
|
|
51
42
|
throw new Error('Could not detetermine bucket from ratios');
|
|
52
43
|
}
|
|
53
|
-
return bucket
|
|
44
|
+
return bucket;
|
|
54
45
|
};
|
|
55
46
|
exports.determineBucket = determineBucket;
|
|
56
47
|
/**
|
|
57
48
|
* Validate the total ratio of the buckets equals 100
|
|
58
|
-
*
|
|
59
|
-
* @param buckets
|
|
60
|
-
* @returns
|
|
61
49
|
*/
|
|
62
50
|
const validateTotalBucketRatio = (buckets) => {
|
|
63
51
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
|
|
@@ -83,10 +71,6 @@ const validateSegmentationRules = (rules, segmentationData) => {
|
|
|
83
71
|
exports.validateSegmentationRules = validateSegmentationRules;
|
|
84
72
|
/**
|
|
85
73
|
* Validate a users segmentation data against a single rule
|
|
86
|
-
*
|
|
87
|
-
* @param rule
|
|
88
|
-
* @param segmentationData
|
|
89
|
-
* @returns
|
|
90
74
|
*/
|
|
91
75
|
const validateSegmentationRule = (rule, data) => {
|
|
92
76
|
const { key, value, operator } = rule;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"prepare": "husky install",
|
|
6
6
|
"build": "build",
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
"dependencies": {
|
|
10
10
|
"@naturalcycles/db-lib": "^8.40.1",
|
|
11
11
|
"@naturalcycles/js-lib": "^14.98.2",
|
|
12
|
+
"@naturalcycles/nodejs-lib": "^12.70.1",
|
|
12
13
|
"semver": "^7.3.5"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
15
16
|
"@naturalcycles/dev-lib": "^12.19.2",
|
|
16
|
-
"@types/node": "^
|
|
17
|
+
"@types/node": "^17.0.34",
|
|
17
18
|
"@types/semver": "^7.3.9",
|
|
18
|
-
"jest": "^
|
|
19
|
+
"jest": "^28.1.0"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"dist",
|
package/readme.md
CHANGED
|
@@ -154,7 +154,7 @@ async getUserAssignment(
|
|
|
154
154
|
userId: string,
|
|
155
155
|
existingOnly: boolean,
|
|
156
156
|
segmentationData?: SegmentationData,
|
|
157
|
-
): Promise<
|
|
157
|
+
): Promise<GeneratedUserAssignment | null>
|
|
158
158
|
```
|
|
159
159
|
|
|
160
160
|
### Generate user assignments
|
|
@@ -166,7 +166,7 @@ attempt to generate new assignments.
|
|
|
166
166
|
async generateUserAssignments(
|
|
167
167
|
userId: string,
|
|
168
168
|
segmentationData: SegmentationData,
|
|
169
|
-
): Promise<
|
|
169
|
+
): Promise<GeneratedUserAssignment[]>
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
### Getting assignment statistics
|
package/src/abba.ts
CHANGED
|
@@ -1,42 +1,43 @@
|
|
|
1
|
-
import { pMap, Saved } from '@naturalcycles/js-lib'
|
|
1
|
+
import { _assert, _AsyncMemo, pMap, Saved } from '@naturalcycles/js-lib'
|
|
2
|
+
import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
|
|
2
3
|
import {
|
|
3
4
|
AbbaConfig,
|
|
4
5
|
AssignmentStatus,
|
|
5
6
|
Bucket,
|
|
7
|
+
BucketInput,
|
|
6
8
|
Experiment,
|
|
7
9
|
ExperimentWithBuckets,
|
|
10
|
+
GeneratedUserAssignment,
|
|
8
11
|
UserAssignment,
|
|
9
12
|
} from './types'
|
|
10
13
|
import { determineAssignment, validateSegmentationRules, validateTotalBucketRatio } from './util'
|
|
11
14
|
import { ExperimentDao, experimentDao } from './dao/experiment.dao'
|
|
12
15
|
import { UserAssignmentDao, userAssignmentDao } from './dao/userAssignment.dao'
|
|
13
16
|
import { BucketDao, bucketDao } from './dao/bucket.dao'
|
|
14
|
-
import { SegmentationData,
|
|
17
|
+
import { SegmentationData, AssignmentStatistics } from '.'
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
private experimentDao: ExperimentDao
|
|
18
|
-
private bucketDao: BucketDao
|
|
19
|
-
private userAssignmentDao: UserAssignmentDao
|
|
19
|
+
const CACHE_TTL = 600_000 // 10 minutes
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
export class Abba {
|
|
22
|
+
constructor(public cfg: AbbaConfig) {
|
|
23
|
+
const { db } = cfg
|
|
22
24
|
this.experimentDao = experimentDao(db)
|
|
23
25
|
this.bucketDao = bucketDao(db)
|
|
24
26
|
this.userAssignmentDao = userAssignmentDao(db)
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
private experimentDao: ExperimentDao
|
|
30
|
+
private bucketDao: BucketDao
|
|
31
|
+
private userAssignmentDao: UserAssignmentDao
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
|
-
* Returns all experiments
|
|
34
|
+
* Returns all (active and inactive) experiments.
|
|
30
35
|
*
|
|
31
|
-
*
|
|
36
|
+
* Cold method, not cached.
|
|
32
37
|
*/
|
|
33
|
-
async getAllExperiments(
|
|
34
|
-
const
|
|
35
|
-
if (excludeInactive) {
|
|
36
|
-
query.filter('status', '!=', AssignmentStatus.Inactive)
|
|
37
|
-
}
|
|
38
|
+
async getAllExperiments(): Promise<ExperimentWithBuckets[]> {
|
|
39
|
+
const experiments = await this.experimentDao.query().runQuery()
|
|
38
40
|
|
|
39
|
-
const experiments = await this.experimentDao.runQuery(query)
|
|
40
41
|
const buckets = await this.bucketDao
|
|
41
42
|
.query()
|
|
42
43
|
.filter(
|
|
@@ -53,149 +54,190 @@ export class Abba {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
* Returns only active experiments.
|
|
58
|
+
* Hot method.
|
|
59
|
+
* Cached in-memory for N minutes (currently 10).
|
|
60
|
+
*/
|
|
61
|
+
@_AsyncMemo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
62
|
+
async getActiveExperiments(): Promise<ExperimentWithBuckets[]> {
|
|
63
|
+
const experiments = await this.experimentDao
|
|
64
|
+
.query()
|
|
65
|
+
.filter('status', '!=', AssignmentStatus.Inactive)
|
|
66
|
+
.runQuery()
|
|
67
|
+
|
|
68
|
+
const buckets = await this.bucketDao
|
|
69
|
+
.query()
|
|
70
|
+
.filter(
|
|
71
|
+
'experimentId',
|
|
72
|
+
'in',
|
|
73
|
+
experiments.map(e => e.id),
|
|
74
|
+
)
|
|
75
|
+
.runQuery()
|
|
76
|
+
|
|
77
|
+
return experiments.map(experiment => ({
|
|
78
|
+
...experiment,
|
|
79
|
+
buckets: buckets.filter(bucket => bucket.experimentId === experiment.id),
|
|
80
|
+
}))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Creates a new experiment.
|
|
85
|
+
* Cold method.
|
|
61
86
|
*/
|
|
62
87
|
async createExperiment(
|
|
63
88
|
experiment: Experiment,
|
|
64
|
-
buckets:
|
|
89
|
+
buckets: BucketInput[],
|
|
65
90
|
): Promise<ExperimentWithBuckets> {
|
|
66
91
|
if (experiment.status === AssignmentStatus.Active) {
|
|
67
92
|
validateTotalBucketRatio(buckets)
|
|
68
93
|
}
|
|
69
94
|
|
|
70
|
-
|
|
95
|
+
await this.experimentDao.save(experiment)
|
|
71
96
|
|
|
72
97
|
return {
|
|
73
|
-
...
|
|
98
|
+
...(experiment as Saved<Experiment>),
|
|
74
99
|
buckets: await this.bucketDao.saveBatch(
|
|
75
|
-
buckets.map(b => ({ ...b, experimentId:
|
|
100
|
+
buckets.map(b => ({ ...b, experimentId: experiment.id! })),
|
|
76
101
|
),
|
|
77
102
|
}
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
/**
|
|
81
|
-
* Update experiment information, will also validate the buckets ratio if experiment.active is true
|
|
82
|
-
*
|
|
83
|
-
* @param id
|
|
84
|
-
* @param experiment
|
|
85
|
-
* @param rules
|
|
86
|
-
* @param buckets
|
|
87
|
-
* @returns
|
|
106
|
+
* Update experiment information, will also validate the buckets' ratio if experiment.active is true
|
|
107
|
+
* Cold method.
|
|
88
108
|
*/
|
|
89
109
|
async saveExperiment(
|
|
90
|
-
id: number,
|
|
91
|
-
experiment: Experiment,
|
|
110
|
+
experiment: Experiment & { id: number },
|
|
92
111
|
buckets: Bucket[],
|
|
93
112
|
): Promise<ExperimentWithBuckets> {
|
|
94
113
|
if (experiment.status === AssignmentStatus.Active) {
|
|
95
114
|
validateTotalBucketRatio(buckets)
|
|
96
115
|
}
|
|
97
116
|
|
|
98
|
-
|
|
99
|
-
...experiment,
|
|
100
|
-
id,
|
|
101
|
-
})
|
|
117
|
+
await this.experimentDao.save(experiment)
|
|
102
118
|
|
|
103
119
|
return {
|
|
104
|
-
...
|
|
105
|
-
buckets: await this.bucketDao.saveBatch(
|
|
120
|
+
...(experiment as Saved<Experiment>),
|
|
121
|
+
buckets: await this.bucketDao.saveBatch(
|
|
122
|
+
buckets.map(b => ({ ...b, experimentId: experiment.id })),
|
|
123
|
+
),
|
|
106
124
|
}
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
/**
|
|
110
|
-
* Delete an experiment. Removes all user assignments and buckets
|
|
111
|
-
*
|
|
112
|
-
* @param id
|
|
128
|
+
* Delete an experiment. Removes all user assignments and buckets.
|
|
129
|
+
* Cold method.
|
|
113
130
|
*/
|
|
114
131
|
async deleteExperiment(id: number): Promise<void> {
|
|
115
132
|
await this.experimentDao.deleteById(id)
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
/**
|
|
119
|
-
* Get an assignment for a given user. If existingOnly is false, it will attempt generate a new assignment
|
|
136
|
+
* Get an assignment for a given user. If existingOnly is false, it will attempt to generate a new assignment
|
|
137
|
+
* Cold method.
|
|
120
138
|
*
|
|
121
139
|
* @param experimentId
|
|
122
140
|
* @param userId
|
|
123
141
|
* @param existingOnly Do not generate any new assignments for this experiment
|
|
124
142
|
* @param segmentationData Required if existingOnly is false
|
|
125
|
-
* @returns
|
|
126
143
|
*/
|
|
127
144
|
async getUserAssignment(
|
|
128
145
|
experimentId: number,
|
|
129
146
|
userId: string,
|
|
130
147
|
existingOnly: boolean,
|
|
131
148
|
segmentationData?: SegmentationData,
|
|
132
|
-
): Promise<
|
|
149
|
+
): Promise<GeneratedUserAssignment | null> {
|
|
150
|
+
const existing = await this.getExistingUserAssignment(experimentId, userId)
|
|
151
|
+
|
|
152
|
+
if (existing) {
|
|
153
|
+
const experiment = await this.experimentDao.requireById(experimentId)
|
|
154
|
+
const bucket = await this.bucketDao.getById(existing.bucketId || undefined)
|
|
155
|
+
return {
|
|
156
|
+
...existing,
|
|
157
|
+
experimentName: experiment.name,
|
|
158
|
+
bucketKey: bucket?.key || null,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (existingOnly) return null
|
|
163
|
+
|
|
133
164
|
const experiment = await this.experimentDao.requireById(experimentId)
|
|
134
|
-
if (
|
|
165
|
+
if (experiment.status !== AssignmentStatus.Active) return null
|
|
135
166
|
|
|
136
|
-
|
|
167
|
+
_assert(segmentationData, 'Segmentation data required when creating a new assignment')
|
|
137
168
|
|
|
138
|
-
const
|
|
139
|
-
if (existing) return existing
|
|
169
|
+
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
140
170
|
|
|
141
|
-
|
|
171
|
+
const assignment = this.generateUserAssignmentData(
|
|
172
|
+
{ ...experiment, buckets },
|
|
173
|
+
userId,
|
|
174
|
+
segmentationData,
|
|
175
|
+
)
|
|
176
|
+
if (!assignment) return null
|
|
142
177
|
|
|
143
|
-
|
|
144
|
-
throw new Error('Segmentation data required when creating a new assignment')
|
|
178
|
+
const saved = await this.userAssignmentDao.save(assignment)
|
|
145
179
|
|
|
146
|
-
return
|
|
180
|
+
return {
|
|
181
|
+
...saved,
|
|
182
|
+
experimentName: experiment.name,
|
|
183
|
+
bucketKey: buckets.find(b => b.id === saved.bucketId)?.key || null,
|
|
184
|
+
}
|
|
147
185
|
}
|
|
148
186
|
|
|
149
187
|
/**
|
|
150
|
-
* Get all existing user assignments
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* @returns
|
|
188
|
+
* Get all existing user assignments.
|
|
189
|
+
* Hot method.
|
|
190
|
+
* Not cached, because Assignments are fast-changing.
|
|
154
191
|
*/
|
|
155
192
|
async getAllExistingUserAssignments(userId: string): Promise<Saved<UserAssignment>[]> {
|
|
156
193
|
return await this.userAssignmentDao.getBy('userId', userId)
|
|
157
194
|
}
|
|
158
195
|
|
|
159
196
|
/**
|
|
160
|
-
* Generate user assignments for all active experiments.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* @param segmentationData
|
|
164
|
-
* @returns
|
|
197
|
+
* Generate user assignments for all active experiments.
|
|
198
|
+
* Will return any existing and attempt to generate any new assignments.
|
|
199
|
+
* Hot method.
|
|
165
200
|
*/
|
|
166
201
|
async generateUserAssignments(
|
|
167
202
|
userId: string,
|
|
168
203
|
segmentationData: SegmentationData,
|
|
169
|
-
): Promise<
|
|
170
|
-
const experiments = await this.
|
|
204
|
+
): Promise<GeneratedUserAssignment[]> {
|
|
205
|
+
const experiments = await this.getActiveExperiments() // cached
|
|
171
206
|
const existingAssignments = await this.getAllExistingUserAssignments(userId)
|
|
172
207
|
|
|
173
|
-
const
|
|
174
|
-
const
|
|
208
|
+
const newAssignments: UserAssignment[] = []
|
|
209
|
+
for (const experiment of experiments) {
|
|
210
|
+
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
|
|
211
|
+
if (!existing) {
|
|
212
|
+
const assignment = this.generateUserAssignmentData(experiment, userId, segmentationData)
|
|
213
|
+
if (assignment) {
|
|
214
|
+
newAssignments.push(assignment)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
existingAssignments.push(...(await this.userAssignmentDao.saveBatch(newAssignments)))
|
|
175
220
|
|
|
221
|
+
const assignments: GeneratedUserAssignment[] = []
|
|
176
222
|
for (const experiment of experiments) {
|
|
177
223
|
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id)
|
|
178
224
|
if (existing) {
|
|
179
|
-
assignments.push(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
225
|
+
assignments.push({
|
|
226
|
+
...existing,
|
|
227
|
+
experimentName: experiment.name,
|
|
228
|
+
bucketKey: experiment.buckets.find(i => i.id === existing.bucketId)?.key || null,
|
|
229
|
+
})
|
|
183
230
|
}
|
|
184
231
|
}
|
|
185
|
-
|
|
186
|
-
const generated = await Promise.all(generatedAssignments)
|
|
187
|
-
const filtered = generated.filter((ua): ua is Saved<UserAssignment> => ua !== null)
|
|
188
|
-
return [...assignments, ...filtered]
|
|
232
|
+
return assignments
|
|
189
233
|
}
|
|
190
234
|
|
|
191
235
|
/**
|
|
192
|
-
* Get assignment statistics for an experiment
|
|
193
|
-
*
|
|
194
|
-
* @param experimentId
|
|
195
|
-
* @returns
|
|
236
|
+
* Get assignment statistics for an experiment.
|
|
237
|
+
* Cold method.
|
|
196
238
|
*/
|
|
197
239
|
async getExperimentAssignmentStatistics(experimentId: number): Promise<AssignmentStatistics> {
|
|
198
|
-
const statistics = {
|
|
240
|
+
const statistics: AssignmentStatistics = {
|
|
199
241
|
sampled: await this.userAssignmentDao
|
|
200
242
|
.query()
|
|
201
243
|
.filterEq('experimentId', experimentId)
|
|
@@ -205,52 +247,49 @@ export class Abba {
|
|
|
205
247
|
|
|
206
248
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId)
|
|
207
249
|
await pMap(buckets, async bucket => {
|
|
208
|
-
|
|
209
|
-
|
|
250
|
+
statistics.buckets[bucket.id] = await this.userAssignmentDao
|
|
251
|
+
.query()
|
|
252
|
+
.filterEq('bucketId', bucket.id)
|
|
253
|
+
.runQueryCount()
|
|
210
254
|
})
|
|
211
255
|
|
|
212
256
|
return statistics
|
|
213
257
|
}
|
|
214
258
|
|
|
215
259
|
/**
|
|
216
|
-
* Generate a new assignment for a given user
|
|
217
|
-
*
|
|
218
|
-
* @param experimentId
|
|
219
|
-
* @param userId
|
|
220
|
-
* @param segmentationData
|
|
221
|
-
* @returns
|
|
260
|
+
* Generate a new assignment for a given user.
|
|
261
|
+
* Doesn't save it.
|
|
222
262
|
*/
|
|
223
|
-
private
|
|
263
|
+
private generateUserAssignmentData(
|
|
224
264
|
experiment: ExperimentWithBuckets,
|
|
225
265
|
userId: string,
|
|
226
266
|
segmentationData: SegmentationData,
|
|
227
|
-
):
|
|
228
|
-
const segmentationMatch = validateSegmentationRules(
|
|
229
|
-
experiment.rules as unknown as SegmentationRule[],
|
|
230
|
-
segmentationData,
|
|
231
|
-
)
|
|
267
|
+
): UserAssignment | null {
|
|
268
|
+
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData)
|
|
232
269
|
if (!segmentationMatch) return null
|
|
233
270
|
|
|
234
|
-
|
|
271
|
+
const bucket = determineAssignment(experiment.sampling, experiment.buckets)
|
|
272
|
+
|
|
273
|
+
return {
|
|
235
274
|
userId,
|
|
236
275
|
experimentId: experiment.id,
|
|
237
|
-
bucketId:
|
|
238
|
-
}
|
|
276
|
+
bucketId: bucket?.id || null,
|
|
277
|
+
}
|
|
239
278
|
}
|
|
240
279
|
|
|
241
280
|
/**
|
|
242
281
|
* Queries to retrieve an existing user assignment for a given experiment
|
|
243
|
-
*
|
|
244
|
-
* @param experimentId
|
|
245
|
-
* @param userId
|
|
246
|
-
* @returns
|
|
247
282
|
*/
|
|
248
283
|
private async getExistingUserAssignment(
|
|
249
284
|
experimentId: number,
|
|
250
285
|
userId: string,
|
|
251
286
|
): Promise<Saved<UserAssignment> | null> {
|
|
252
|
-
const
|
|
253
|
-
|
|
287
|
+
const [assignment] = await this.userAssignmentDao
|
|
288
|
+
.query()
|
|
289
|
+
.filterEq('userId', userId)
|
|
290
|
+
.filterEq('experimentId', experimentId)
|
|
291
|
+
.runQuery()
|
|
292
|
+
|
|
254
293
|
return assignment || null
|
|
255
294
|
}
|
|
256
295
|
}
|
|
@@ -17,6 +17,6 @@ export const experimentDao = (db: CommonDB): ExperimentDao =>
|
|
|
17
17
|
assignGeneratedIds: true,
|
|
18
18
|
hooks: {
|
|
19
19
|
beforeBMToDBM: bm => ({ ...bm, rules: bm.rules.length ? JSON.stringify(bm.rules) : null }),
|
|
20
|
-
beforeDBMToBM: dbm => ({ ...dbm, rules: dbm.rules && JSON.parse(dbm.rules) }),
|
|
20
|
+
beforeDBMToBM: dbm => ({ ...dbm, rules: (dbm.rules && JSON.parse(dbm.rules)) || [] }),
|
|
21
21
|
},
|
|
22
22
|
})
|
package/src/types.ts
CHANGED
|
@@ -3,6 +3,14 @@ import { 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
|
|
6
14
|
}
|
|
7
15
|
|
|
8
16
|
export type BaseExperiment = BaseDBEntity<number> & {
|
|
@@ -38,6 +46,11 @@ export type UserAssignment = BaseDBEntity<number> & {
|
|
|
38
46
|
bucketId: number | null
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
export type GeneratedUserAssignment = Saved<UserAssignment> & {
|
|
50
|
+
experimentName: string
|
|
51
|
+
bucketKey: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export type SegmentationData = Record<string, string | boolean | number>
|
|
42
55
|
|
|
43
56
|
export enum AssignmentStatus {
|
package/src/util.ts
CHANGED
|
@@ -4,8 +4,6 @@ import { Bucket, SegmentationData, SegmentationRule } from './types'
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Generate a random number between 0 and 100
|
|
7
|
-
*
|
|
8
|
-
* @returns
|
|
9
7
|
*/
|
|
10
8
|
export const rollDie = (): number => {
|
|
11
9
|
return Math.random() * 100
|
|
@@ -13,12 +11,8 @@ export const rollDie = (): number => {
|
|
|
13
11
|
|
|
14
12
|
/**
|
|
15
13
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
16
|
-
*
|
|
17
|
-
* @param sampling
|
|
18
|
-
* @param buckets
|
|
19
|
-
* @returns
|
|
20
14
|
*/
|
|
21
|
-
export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]):
|
|
15
|
+
export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]): Bucket | null => {
|
|
22
16
|
// Should this person be considered for the experiment?
|
|
23
17
|
if (rollDie() > sampling) {
|
|
24
18
|
return null
|
|
@@ -30,11 +24,8 @@ export const determineAssignment = (sampling: number, buckets: Saved<Bucket>[]):
|
|
|
30
24
|
|
|
31
25
|
/**
|
|
32
26
|
* Determines which bucket a user assignment will recieve
|
|
33
|
-
*
|
|
34
|
-
* @param buckets
|
|
35
|
-
* @returns
|
|
36
27
|
*/
|
|
37
|
-
export const determineBucket = (buckets: Saved<Bucket>[]):
|
|
28
|
+
export const determineBucket = (buckets: Saved<Bucket>[]): Bucket => {
|
|
38
29
|
const bucketRoll = rollDie()
|
|
39
30
|
let range: [number, number] | undefined
|
|
40
31
|
const bucket = buckets.find(b => {
|
|
@@ -53,14 +44,11 @@ export const determineBucket = (buckets: Saved<Bucket>[]): number => {
|
|
|
53
44
|
throw new Error('Could not detetermine bucket from ratios')
|
|
54
45
|
}
|
|
55
46
|
|
|
56
|
-
return bucket
|
|
47
|
+
return bucket
|
|
57
48
|
}
|
|
58
49
|
|
|
59
50
|
/**
|
|
60
51
|
* Validate the total ratio of the buckets equals 100
|
|
61
|
-
*
|
|
62
|
-
* @param buckets
|
|
63
|
-
* @returns
|
|
64
52
|
*/
|
|
65
53
|
export const validateTotalBucketRatio = (buckets: Bucket[]): void => {
|
|
66
54
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0)
|
|
@@ -88,10 +76,6 @@ export const validateSegmentationRules = (
|
|
|
88
76
|
|
|
89
77
|
/**
|
|
90
78
|
* Validate a users segmentation data against a single rule
|
|
91
|
-
*
|
|
92
|
-
* @param rule
|
|
93
|
-
* @param segmentationData
|
|
94
|
-
* @returns
|
|
95
79
|
*/
|
|
96
80
|
export const validateSegmentationRule = (
|
|
97
81
|
rule: SegmentationRule,
|