@naturalcycles/abba 1.27.0 → 2.0.1
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 +2 -3
- package/dist/abba.js +35 -39
- package/dist/dao/bucket.dao.d.ts +4 -3
- package/dist/dao/bucket.dao.js +3 -8
- package/dist/dao/experiment.dao.d.ts +4 -3
- package/dist/dao/experiment.dao.js +5 -10
- package/dist/dao/userAssignment.dao.d.ts +3 -2
- package/dist/dao/userAssignment.dao.js +3 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -5
- package/dist/types.d.ts +2 -2
- package/dist/types.js +4 -7
- package/dist/util.d.ts +11 -6
- package/dist/util.js +36 -44
- package/package.json +12 -9
- package/src/abba.ts +10 -9
- package/src/dao/bucket.dao.ts +4 -3
- package/src/dao/experiment.dao.ts +5 -3
- package/src/dao/userAssignment.dao.ts +3 -2
- package/src/index.ts +2 -2
- package/src/types.ts +2 -2
- package/src/util.ts +16 -12
package/dist/abba.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
-
import { SegmentationData } from '.';
|
|
3
|
-
import { AbbaConfig, Bucket, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, GeneratedUserAssignment } from './types';
|
|
1
|
+
import type { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
+
import type { AbbaConfig, Bucket, Experiment, ExperimentAssignmentStatistics, ExperimentWithBuckets, GeneratedUserAssignment, SegmentationData } from './types.js';
|
|
4
3
|
export declare class Abba {
|
|
5
4
|
cfg: AbbaConfig;
|
|
6
5
|
private experimentDao;
|
package/dist/abba.js
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const userAssignment_dao_1 = require("./dao/userAssignment.dao");
|
|
10
|
-
const types_1 = require("./types");
|
|
11
|
-
const util_1 = require("./util");
|
|
1
|
+
import { __decorate } from "tslib";
|
|
2
|
+
import { _assert, _Memo, _shuffle, localTime, pMap } from '@naturalcycles/js-lib';
|
|
3
|
+
import { LRUMemoCache } from '@naturalcycles/nodejs-lib';
|
|
4
|
+
import { bucketDao } from './dao/bucket.dao.js';
|
|
5
|
+
import { experimentDao } from './dao/experiment.dao.js';
|
|
6
|
+
import { userAssignmentDao } from './dao/userAssignment.dao.js';
|
|
7
|
+
import { AssignmentStatus } from './types.js';
|
|
8
|
+
import { canGenerateNewAssignments, generateUserAssignmentData, getUserExclusionSet, validateTotalBucketRatio, } from './util.js';
|
|
12
9
|
/**
|
|
13
10
|
* 10 minutes
|
|
14
11
|
*/
|
|
15
12
|
const CACHE_TTL = 600_000;
|
|
16
|
-
class Abba {
|
|
13
|
+
export class Abba {
|
|
17
14
|
constructor(cfg) {
|
|
18
15
|
this.cfg = cfg;
|
|
19
|
-
this.experimentDao =
|
|
20
|
-
this.bucketDao =
|
|
21
|
-
this.userAssignmentDao =
|
|
16
|
+
this.experimentDao = experimentDao(this.cfg.db);
|
|
17
|
+
this.bucketDao = bucketDao(this.cfg.db);
|
|
18
|
+
this.userAssignmentDao = userAssignmentDao(this.cfg.db);
|
|
22
19
|
}
|
|
23
20
|
/**
|
|
24
21
|
* Returns all experiments.
|
|
@@ -50,8 +47,8 @@ class Abba {
|
|
|
50
47
|
* Cold method.
|
|
51
48
|
*/
|
|
52
49
|
async createExperiment(experiment, buckets) {
|
|
53
|
-
if (experiment.status ===
|
|
54
|
-
|
|
50
|
+
if (experiment.status === AssignmentStatus.Active) {
|
|
51
|
+
validateTotalBucketRatio(buckets);
|
|
55
52
|
}
|
|
56
53
|
const created = await this.experimentDao.save(experiment);
|
|
57
54
|
const createdbuckets = await this.bucketDao.saveBatch(buckets.map(b => ({ ...b, experimentId: created.id })));
|
|
@@ -66,11 +63,11 @@ class Abba {
|
|
|
66
63
|
* Cold method.
|
|
67
64
|
*/
|
|
68
65
|
async saveExperiment(experiment, buckets) {
|
|
69
|
-
if (experiment.status ===
|
|
70
|
-
|
|
66
|
+
if (experiment.status === AssignmentStatus.Active) {
|
|
67
|
+
validateTotalBucketRatio(buckets);
|
|
71
68
|
}
|
|
72
69
|
const updatedExperiment = await this.experimentDao.save(experiment, { saveMethod: 'update' });
|
|
73
|
-
const updatedBuckets = await
|
|
70
|
+
const updatedBuckets = await pMap(buckets, async (bucket) => {
|
|
74
71
|
return await this.bucketDao.save({ ...bucket, experimentId: updatedExperiment.id }, { saveMethod: bucket.id ? 'update' : undefined });
|
|
75
72
|
});
|
|
76
73
|
await this.updateExclusions(updatedExperiment.id, updatedExperiment.exclusions);
|
|
@@ -109,9 +106,9 @@ class Abba {
|
|
|
109
106
|
*/
|
|
110
107
|
async deleteExperiment(experimentId) {
|
|
111
108
|
const experiment = await this.experimentDao.requireById(experimentId);
|
|
112
|
-
const hasBeenInactiveFor15Mins = experiment.status ===
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
const hasBeenInactiveFor15Mins = experiment.status === AssignmentStatus.Inactive &&
|
|
110
|
+
localTime(experiment.updated).isOlderThan(15, 'minute');
|
|
111
|
+
_assert(hasBeenInactiveFor15Mins, 'Experiment must be inactive for at least 15 minutes before deletion');
|
|
115
112
|
const userAssignmentDeleteQuery = this.userAssignmentDao
|
|
116
113
|
.query()
|
|
117
114
|
.filterEq('experimentId', experimentId);
|
|
@@ -132,9 +129,9 @@ class Abba {
|
|
|
132
129
|
*/
|
|
133
130
|
async getUserAssignment(experimentKey, userId, existingOnly, segmentationData) {
|
|
134
131
|
const experiment = await this.experimentDao.getOneBy('key', experimentKey);
|
|
135
|
-
|
|
132
|
+
_assert(experiment, `Experiment does not exist: ${experimentKey}`);
|
|
136
133
|
// Inactive experiments should never return an assignment
|
|
137
|
-
if (experiment.status ===
|
|
134
|
+
if (experiment.status === AssignmentStatus.Inactive) {
|
|
138
135
|
return {
|
|
139
136
|
experiment,
|
|
140
137
|
assignment: null,
|
|
@@ -156,23 +153,23 @@ class Abba {
|
|
|
156
153
|
};
|
|
157
154
|
}
|
|
158
155
|
// No existing assignment, but we don't want to generate a new one
|
|
159
|
-
if (existingOnly || experiment.status ===
|
|
156
|
+
if (existingOnly || experiment.status === AssignmentStatus.Paused) {
|
|
160
157
|
return {
|
|
161
158
|
experiment,
|
|
162
159
|
assignment: null,
|
|
163
160
|
};
|
|
164
161
|
}
|
|
165
162
|
const experiments = await this.getAllExperiments();
|
|
166
|
-
const exclusionSet =
|
|
167
|
-
if (!
|
|
163
|
+
const exclusionSet = getUserExclusionSet(experiments, existingAssignments);
|
|
164
|
+
if (!canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
168
165
|
return {
|
|
169
166
|
experiment,
|
|
170
167
|
assignment: null,
|
|
171
168
|
};
|
|
172
169
|
}
|
|
173
|
-
|
|
170
|
+
_assert(segmentationData, 'Segmentation data required when creating a new assignment');
|
|
174
171
|
const experimentWithBuckets = { ...experiment, buckets };
|
|
175
|
-
const assignment =
|
|
172
|
+
const assignment = generateUserAssignmentData(experimentWithBuckets, userId, segmentationData);
|
|
176
173
|
if (!assignment) {
|
|
177
174
|
return {
|
|
178
175
|
experiment,
|
|
@@ -199,7 +196,7 @@ class Abba {
|
|
|
199
196
|
*/
|
|
200
197
|
async getAllExistingGeneratedUserAssignments(userId) {
|
|
201
198
|
const assignments = await this.userAssignmentDao.getBy('userId', userId);
|
|
202
|
-
return await
|
|
199
|
+
return await pMap(assignments, async (assignment) => {
|
|
203
200
|
const experiment = await this.experimentDao.requireById(assignment.experimentId);
|
|
204
201
|
const bucket = await this.bucketDao.getById(assignment.bucketId);
|
|
205
202
|
return {
|
|
@@ -221,13 +218,13 @@ class Abba {
|
|
|
221
218
|
async generateUserAssignments(userId, segmentationData, existingOnly = false) {
|
|
222
219
|
const experiments = await this.getAllExperiments();
|
|
223
220
|
const existingAssignments = await this.userAssignmentDao.getBy('userId', userId);
|
|
224
|
-
const exclusionSet =
|
|
221
|
+
const exclusionSet = getUserExclusionSet(experiments, existingAssignments);
|
|
225
222
|
const assignments = [];
|
|
226
223
|
const newAssignments = [];
|
|
227
224
|
// Shuffling means that randomisation occurs in the mutual exclusion
|
|
228
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
|
|
229
226
|
// This is simmpler than trying to resolve after assignments have already been determined
|
|
230
|
-
const availableExperiments =
|
|
227
|
+
const availableExperiments = _shuffle(experiments.filter(e => e.status === AssignmentStatus.Active || e.status === AssignmentStatus.Paused));
|
|
231
228
|
for (const experiment of availableExperiments) {
|
|
232
229
|
const existing = existingAssignments.find(ua => ua.experimentId === experiment.id);
|
|
233
230
|
if (existing) {
|
|
@@ -242,8 +239,8 @@ class Abba {
|
|
|
242
239
|
},
|
|
243
240
|
});
|
|
244
241
|
}
|
|
245
|
-
else if (!existingOnly &&
|
|
246
|
-
const assignment =
|
|
242
|
+
else if (!existingOnly && canGenerateNewAssignments(experiment, exclusionSet)) {
|
|
243
|
+
const assignment = generateUserAssignmentData(experiment, userId, segmentationData);
|
|
247
244
|
if (assignment) {
|
|
248
245
|
const created = this.userAssignmentDao.create(assignment);
|
|
249
246
|
newAssignments.push(created);
|
|
@@ -275,7 +272,7 @@ class Abba {
|
|
|
275
272
|
.filterEq('experimentId', experimentId)
|
|
276
273
|
.runQueryCount();
|
|
277
274
|
const buckets = await this.bucketDao.getBy('experimentId', experimentId);
|
|
278
|
-
const bucketAssignments = await
|
|
275
|
+
const bucketAssignments = await pMap(buckets, async (bucket) => {
|
|
279
276
|
const totalAssignments = await this.userAssignmentDao
|
|
280
277
|
.query()
|
|
281
278
|
.filterEq('bucketId', bucket.id)
|
|
@@ -291,7 +288,6 @@ class Abba {
|
|
|
291
288
|
};
|
|
292
289
|
}
|
|
293
290
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
(0, js_lib_1._Memo)({ cacheFactory: () => new nodejs_lib_1.LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
291
|
+
__decorate([
|
|
292
|
+
_Memo({ cacheFactory: () => new LRUMemoCache({ ttl: CACHE_TTL, max: 1 }) })
|
|
297
293
|
], Abba.prototype, "getAllExperiments", null);
|
package/dist/dao/bucket.dao.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
|
+
import { CommonDao } from '@naturalcycles/db-lib';
|
|
3
|
+
import type { Saved } from '@naturalcycles/js-lib';
|
|
4
|
+
import type { BaseBucket, Bucket } from '../types.js';
|
|
4
5
|
type BucketDBM = Saved<BaseBucket> & {
|
|
5
6
|
data: string | null;
|
|
6
7
|
};
|
package/dist/dao/bucket.dao.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
exports.bucketDao = exports.BucketDao = void 0;
|
|
4
|
-
const db_lib_1 = require("@naturalcycles/db-lib");
|
|
5
|
-
class BucketDao extends db_lib_1.CommonDao {
|
|
1
|
+
import { CommonDao } from '@naturalcycles/db-lib';
|
|
2
|
+
export class BucketDao extends CommonDao {
|
|
6
3
|
}
|
|
7
|
-
|
|
8
|
-
const bucketDao = (db) => new BucketDao({
|
|
4
|
+
export const bucketDao = (db) => new BucketDao({
|
|
9
5
|
db,
|
|
10
6
|
table: 'Bucket',
|
|
11
7
|
hooks: {
|
|
@@ -21,4 +17,3 @@ const bucketDao = (db) => new BucketDao({
|
|
|
21
17
|
}),
|
|
22
18
|
},
|
|
23
19
|
});
|
|
24
|
-
exports.bucketDao = bucketDao;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
|
+
import { CommonDao } from '@naturalcycles/db-lib';
|
|
3
|
+
import type { Saved } from '@naturalcycles/js-lib';
|
|
4
|
+
import type { BaseExperiment, Experiment } from '../types.js';
|
|
4
5
|
type ExperimentDBM = Saved<BaseExperiment> & {
|
|
5
6
|
rules: string | null;
|
|
6
7
|
exclusions: string | null;
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const db_lib_1 = require("@naturalcycles/db-lib");
|
|
5
|
-
const js_lib_1 = require("@naturalcycles/js-lib");
|
|
6
|
-
class ExperimentDao extends db_lib_1.CommonDao {
|
|
1
|
+
import { CommonDao } from '@naturalcycles/db-lib';
|
|
2
|
+
import { localDate } from '@naturalcycles/js-lib';
|
|
3
|
+
export class ExperimentDao extends CommonDao {
|
|
7
4
|
}
|
|
8
|
-
|
|
9
|
-
const experimentDao = (db) => new ExperimentDao({
|
|
5
|
+
export const experimentDao = (db) => new ExperimentDao({
|
|
10
6
|
db,
|
|
11
7
|
table: 'Experiment',
|
|
12
8
|
hooks: {
|
|
@@ -34,7 +30,6 @@ const experimentDao = (db) => new ExperimentDao({
|
|
|
34
30
|
}),
|
|
35
31
|
},
|
|
36
32
|
});
|
|
37
|
-
exports.experimentDao = experimentDao;
|
|
38
33
|
/**
|
|
39
34
|
* https://nc1.slack.com/archives/CCNTHJT7V/p1682514277002739
|
|
40
35
|
* MySQL Automatically parses Date fields as Date objects
|
|
@@ -43,6 +38,6 @@ exports.experimentDao = experimentDao;
|
|
|
43
38
|
function parseMySQLDate(date) {
|
|
44
39
|
// @ts-expect-error
|
|
45
40
|
if (date instanceof Date)
|
|
46
|
-
return
|
|
41
|
+
return localDate(date).toISODate();
|
|
47
42
|
return date;
|
|
48
43
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
|
+
import { CommonDao } from '@naturalcycles/db-lib';
|
|
3
|
+
import type { UserAssignment } from '../types.js';
|
|
3
4
|
export declare class UserAssignmentDao extends CommonDao<UserAssignment> {
|
|
4
5
|
}
|
|
5
6
|
export declare const userAssignmentDao: (db: CommonDB) => UserAssignmentDao;
|
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
exports.userAssignmentDao = exports.UserAssignmentDao = void 0;
|
|
4
|
-
const db_lib_1 = require("@naturalcycles/db-lib");
|
|
5
|
-
class UserAssignmentDao extends db_lib_1.CommonDao {
|
|
1
|
+
import { CommonDao } from '@naturalcycles/db-lib';
|
|
2
|
+
export class UserAssignmentDao extends CommonDao {
|
|
6
3
|
}
|
|
7
|
-
|
|
8
|
-
const userAssignmentDao = (db) => new UserAssignmentDao({
|
|
4
|
+
export const userAssignmentDao = (db) => new UserAssignmentDao({
|
|
9
5
|
db,
|
|
10
6
|
table: 'UserAssignment',
|
|
11
7
|
});
|
|
12
|
-
exports.userAssignmentDao = userAssignmentDao;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './abba';
|
|
2
|
-
export * from './types';
|
|
1
|
+
export * from './abba.js';
|
|
2
|
+
export * from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const tslib_1 = require("tslib");
|
|
4
|
-
tslib_1.__exportStar(require("./abba"), exports);
|
|
5
|
-
tslib_1.__exportStar(require("./types"), exports);
|
|
1
|
+
export * from './abba.js';
|
|
2
|
+
export * from './types.js';
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CommonDB } from '@naturalcycles/db-lib';
|
|
2
|
-
import { AnyObject, BaseDBEntity, IsoDate, Saved } from '@naturalcycles/js-lib';
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib';
|
|
2
|
+
import type { AnyObject, BaseDBEntity, IsoDate, Saved } from '@naturalcycles/js-lib';
|
|
3
3
|
export interface AbbaConfig {
|
|
4
4
|
db: CommonDB;
|
|
5
5
|
}
|
package/dist/types.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SegmentationRuleOperator = exports.AssignmentStatus = void 0;
|
|
4
|
-
var AssignmentStatus;
|
|
1
|
+
export var AssignmentStatus;
|
|
5
2
|
(function (AssignmentStatus) {
|
|
6
3
|
/**
|
|
7
4
|
* Will return existing assignments and generate new assignments
|
|
@@ -15,8 +12,8 @@ var AssignmentStatus;
|
|
|
15
12
|
* Will not return any assignments
|
|
16
13
|
*/
|
|
17
14
|
AssignmentStatus[AssignmentStatus["Inactive"] = 3] = "Inactive";
|
|
18
|
-
})(AssignmentStatus || (
|
|
19
|
-
var SegmentationRuleOperator;
|
|
15
|
+
})(AssignmentStatus || (AssignmentStatus = {}));
|
|
16
|
+
export var SegmentationRuleOperator;
|
|
20
17
|
(function (SegmentationRuleOperator) {
|
|
21
18
|
SegmentationRuleOperator["IsSet"] = "isSet";
|
|
22
19
|
SegmentationRuleOperator["IsNotSet"] = "isNotSet";
|
|
@@ -26,4 +23,4 @@ var SegmentationRuleOperator;
|
|
|
26
23
|
SegmentationRuleOperator["Regex"] = "regex";
|
|
27
24
|
/* eslint-disable id-blacklist*/
|
|
28
25
|
SegmentationRuleOperator["Boolean"] = "boolean";
|
|
29
|
-
})(SegmentationRuleOperator || (
|
|
26
|
+
})(SegmentationRuleOperator || (SegmentationRuleOperator = {}));
|
package/dist/util.d.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
-
import { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn,
|
|
1
|
+
import type { Unsaved } from '@naturalcycles/js-lib';
|
|
2
|
+
import type { Bucket, ExclusionSet, Experiment, ExperimentWithBuckets, SegmentationData, SegmentationRule, SegmentationRuleFn, UserAssignment } from './types.js';
|
|
3
|
+
import { SegmentationRuleOperator } from './types.js';
|
|
3
4
|
/**
|
|
4
5
|
* Generate a new assignment for a given user.
|
|
5
6
|
* Doesn't save it.
|
|
6
7
|
*/
|
|
7
8
|
export declare const generateUserAssignmentData: (experiment: ExperimentWithBuckets, userId: string, segmentationData: SegmentationData) => UserAssignment | null;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
declare class RandomService {
|
|
10
|
+
/**
|
|
11
|
+
* Generate a random number between 0 and 100
|
|
12
|
+
*/
|
|
13
|
+
rollDie(): number;
|
|
14
|
+
}
|
|
15
|
+
export declare const randomService: RandomService;
|
|
12
16
|
/**
|
|
13
17
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
14
18
|
*/
|
|
@@ -42,3 +46,4 @@ export declare const canGenerateNewAssignments: (experiment: Experiment, exclusi
|
|
|
42
46
|
* based on a combination of existing assignments and mutual exclusion configuration
|
|
43
47
|
*/
|
|
44
48
|
export declare const getUserExclusionSet: (experiments: Experiment[], existingAssignments: UserAssignment[]) => ExclusionSet;
|
|
49
|
+
export {};
|
package/dist/util.js
CHANGED
|
@@ -1,49 +1,46 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const js_lib_1 = require("@naturalcycles/js-lib");
|
|
5
|
-
const semver_1 = require("semver");
|
|
6
|
-
const types_1 = require("./types");
|
|
1
|
+
import { localDate } from '@naturalcycles/js-lib';
|
|
2
|
+
import { satisfies } from 'semver';
|
|
3
|
+
import { AssignmentStatus, SegmentationRuleOperator } from './types.js';
|
|
7
4
|
/**
|
|
8
5
|
* Generate a new assignment for a given user.
|
|
9
6
|
* Doesn't save it.
|
|
10
7
|
*/
|
|
11
|
-
const generateUserAssignmentData = (experiment, userId, segmentationData) => {
|
|
12
|
-
const segmentationMatch =
|
|
8
|
+
export const generateUserAssignmentData = (experiment, userId, segmentationData) => {
|
|
9
|
+
const segmentationMatch = validateSegmentationRules(experiment.rules, segmentationData);
|
|
13
10
|
if (!segmentationMatch)
|
|
14
11
|
return null;
|
|
15
|
-
const bucket =
|
|
12
|
+
const bucket = determineAssignment(experiment.sampling, experiment.buckets);
|
|
16
13
|
return {
|
|
17
14
|
userId,
|
|
18
15
|
experimentId: experiment.id,
|
|
19
16
|
bucketId: bucket?.id || null,
|
|
20
17
|
};
|
|
21
18
|
};
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
19
|
+
class RandomService {
|
|
20
|
+
/**
|
|
21
|
+
* Generate a random number between 0 and 100
|
|
22
|
+
*/
|
|
23
|
+
rollDie() {
|
|
24
|
+
return Math.random() * 100;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const randomService = new RandomService();
|
|
30
28
|
/**
|
|
31
29
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
32
30
|
*/
|
|
33
|
-
const determineAssignment = (sampling, buckets) => {
|
|
31
|
+
export const determineAssignment = (sampling, buckets) => {
|
|
34
32
|
// Should this person be considered for the experiment?
|
|
35
|
-
if (
|
|
33
|
+
if (randomService.rollDie() > sampling) {
|
|
36
34
|
return null;
|
|
37
35
|
}
|
|
38
36
|
// get their bucket
|
|
39
|
-
return
|
|
37
|
+
return determineBucket(buckets);
|
|
40
38
|
};
|
|
41
|
-
exports.determineAssignment = determineAssignment;
|
|
42
39
|
/**
|
|
43
40
|
* Determines which bucket a user assignment will recieve
|
|
44
41
|
*/
|
|
45
|
-
const determineBucket = (buckets) => {
|
|
46
|
-
const bucketRoll =
|
|
42
|
+
export const determineBucket = (buckets) => {
|
|
43
|
+
const bucketRoll = randomService.rollDie();
|
|
47
44
|
let range;
|
|
48
45
|
const bucket = buckets.find(b => {
|
|
49
46
|
if (!range) {
|
|
@@ -61,17 +58,15 @@ const determineBucket = (buckets) => {
|
|
|
61
58
|
}
|
|
62
59
|
return bucket;
|
|
63
60
|
};
|
|
64
|
-
exports.determineBucket = determineBucket;
|
|
65
61
|
/**
|
|
66
62
|
* Validate the total ratio of the buckets equals 100
|
|
67
63
|
*/
|
|
68
|
-
const validateTotalBucketRatio = (buckets) => {
|
|
64
|
+
export const validateTotalBucketRatio = (buckets) => {
|
|
69
65
|
const bucketSum = buckets.reduce((sum, current) => sum + current.ratio, 0);
|
|
70
66
|
if (bucketSum !== 100) {
|
|
71
67
|
throw new Error('Total bucket ratio must be 100 before you can activate an experiment');
|
|
72
68
|
}
|
|
73
69
|
};
|
|
74
|
-
exports.validateTotalBucketRatio = validateTotalBucketRatio;
|
|
75
70
|
/**
|
|
76
71
|
* Validate a users segmentation data against multiple rules. Returns false if any fail
|
|
77
72
|
*
|
|
@@ -79,38 +74,37 @@ exports.validateTotalBucketRatio = validateTotalBucketRatio;
|
|
|
79
74
|
* @param segmentationData
|
|
80
75
|
* @returns
|
|
81
76
|
*/
|
|
82
|
-
const validateSegmentationRules = (rules, segmentationData) => {
|
|
77
|
+
export const validateSegmentationRules = (rules, segmentationData) => {
|
|
83
78
|
for (const rule of rules) {
|
|
84
79
|
const { key, value, operator } = rule;
|
|
85
|
-
if (!
|
|
80
|
+
if (!segmentationRuleMap[operator](segmentationData[key], value))
|
|
86
81
|
return false;
|
|
87
82
|
}
|
|
88
83
|
return true;
|
|
89
84
|
};
|
|
90
|
-
exports.validateSegmentationRules = validateSegmentationRules;
|
|
91
85
|
/**
|
|
92
86
|
* Map of segmentation rule validators
|
|
93
87
|
*/
|
|
94
|
-
|
|
95
|
-
[
|
|
88
|
+
export const segmentationRuleMap = {
|
|
89
|
+
[SegmentationRuleOperator.IsSet](keyValue) {
|
|
96
90
|
return !!keyValue;
|
|
97
91
|
},
|
|
98
|
-
[
|
|
92
|
+
[SegmentationRuleOperator.IsNotSet](keyValue) {
|
|
99
93
|
return !keyValue;
|
|
100
94
|
},
|
|
101
|
-
[
|
|
95
|
+
[SegmentationRuleOperator.EqualsText](keyValue, ruleValue) {
|
|
102
96
|
return keyValue?.toString() === ruleValue.toString();
|
|
103
97
|
},
|
|
104
|
-
[
|
|
98
|
+
[SegmentationRuleOperator.NotEqualsText](keyValue, ruleValue) {
|
|
105
99
|
return keyValue?.toString() !== ruleValue.toString();
|
|
106
100
|
},
|
|
107
|
-
[
|
|
108
|
-
return
|
|
101
|
+
[SegmentationRuleOperator.Semver](keyValue, ruleValue) {
|
|
102
|
+
return satisfies(keyValue?.toString() || '', ruleValue.toString());
|
|
109
103
|
},
|
|
110
|
-
[
|
|
104
|
+
[SegmentationRuleOperator.Regex](keyValue, ruleValue) {
|
|
111
105
|
return new RegExp(ruleValue).test(keyValue?.toString() || '');
|
|
112
106
|
},
|
|
113
|
-
[
|
|
107
|
+
[SegmentationRuleOperator.Boolean](keyValue, ruleValue) {
|
|
114
108
|
// If it's true, then must be true
|
|
115
109
|
if (ruleValue === 'true')
|
|
116
110
|
return keyValue?.toString() === 'true';
|
|
@@ -121,17 +115,16 @@ exports.segmentationRuleMap = {
|
|
|
121
115
|
/**
|
|
122
116
|
* Returns true if an experiment is able to generate new assignments based on status and start/end dates
|
|
123
117
|
*/
|
|
124
|
-
const canGenerateNewAssignments = (experiment, exclusionSet) => {
|
|
118
|
+
export const canGenerateNewAssignments = (experiment, exclusionSet) => {
|
|
125
119
|
return (!exclusionSet.has(experiment.id) &&
|
|
126
|
-
experiment.status ===
|
|
127
|
-
|
|
120
|
+
experiment.status === AssignmentStatus.Active &&
|
|
121
|
+
localDate.today().isBetween(experiment.startDateIncl, experiment.endDateExcl, '[)'));
|
|
128
122
|
};
|
|
129
|
-
exports.canGenerateNewAssignments = canGenerateNewAssignments;
|
|
130
123
|
/**
|
|
131
124
|
* Returns an object that includes keys of all experimentIds a user should not be assigned to
|
|
132
125
|
* based on a combination of existing assignments and mutual exclusion configuration
|
|
133
126
|
*/
|
|
134
|
-
const getUserExclusionSet = (experiments, existingAssignments) => {
|
|
127
|
+
export const getUserExclusionSet = (experiments, existingAssignments) => {
|
|
135
128
|
const exclusionSet = new Set();
|
|
136
129
|
existingAssignments.forEach(assignment => {
|
|
137
130
|
// Users who are excluded from an experiment due to sampling
|
|
@@ -143,4 +136,3 @@ const getUserExclusionSet = (experiments, existingAssignments) => {
|
|
|
143
136
|
});
|
|
144
137
|
return exclusionSet;
|
|
145
138
|
};
|
|
146
|
-
exports.getUserExclusionSet = getUserExclusionSet;
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/abba",
|
|
3
|
-
"
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "2.0.1",
|
|
4
5
|
"scripts": {
|
|
5
6
|
"prepare": "husky",
|
|
6
7
|
"build": "dev-lib build",
|
|
@@ -10,16 +11,18 @@
|
|
|
10
11
|
"lbt": "dev-lib lbt"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
|
-
"@naturalcycles/db-lib": "^
|
|
14
|
-
"@naturalcycles/js-lib": "^
|
|
15
|
-
"@naturalcycles/nodejs-lib": "^
|
|
16
|
-
"semver": "^7
|
|
14
|
+
"@naturalcycles/db-lib": "^10",
|
|
15
|
+
"@naturalcycles/js-lib": "^15",
|
|
16
|
+
"@naturalcycles/nodejs-lib": "^14",
|
|
17
|
+
"semver": "^7"
|
|
17
18
|
},
|
|
18
19
|
"devDependencies": {
|
|
19
|
-
"@naturalcycles/dev-lib": "^
|
|
20
|
-
"@types/node": "^22
|
|
21
|
-
"@types/semver": "^7
|
|
22
|
-
"
|
|
20
|
+
"@naturalcycles/dev-lib": "^18",
|
|
21
|
+
"@types/node": "^22",
|
|
22
|
+
"@types/semver": "^7",
|
|
23
|
+
"@vitest/coverage-v8": "^3",
|
|
24
|
+
"tsx": "^4",
|
|
25
|
+
"vitest": "^3"
|
|
23
26
|
},
|
|
24
27
|
"files": [
|
|
25
28
|
"dist",
|
package/src/abba.ts
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Unsaved } from '@naturalcycles/js-lib'
|
|
2
|
+
import { _assert, _Memo, _shuffle, localTime, pMap } from '@naturalcycles/js-lib'
|
|
2
3
|
import { LRUMemoCache } from '@naturalcycles/nodejs-lib'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
4
|
+
import { bucketDao } from './dao/bucket.dao.js'
|
|
5
|
+
import { experimentDao } from './dao/experiment.dao.js'
|
|
6
|
+
import { userAssignmentDao } from './dao/userAssignment.dao.js'
|
|
7
|
+
import type {
|
|
8
8
|
AbbaConfig,
|
|
9
|
-
AssignmentStatus,
|
|
10
9
|
Bucket,
|
|
11
10
|
BucketAssignmentStatistics,
|
|
12
11
|
Experiment,
|
|
13
12
|
ExperimentAssignmentStatistics,
|
|
14
13
|
ExperimentWithBuckets,
|
|
15
14
|
GeneratedUserAssignment,
|
|
15
|
+
SegmentationData,
|
|
16
16
|
UserAssignment,
|
|
17
|
-
} from './types'
|
|
17
|
+
} from './types.js'
|
|
18
|
+
import { AssignmentStatus } from './types.js'
|
|
18
19
|
import {
|
|
19
20
|
canGenerateNewAssignments,
|
|
20
21
|
generateUserAssignmentData,
|
|
21
22
|
getUserExclusionSet,
|
|
22
23
|
validateTotalBucketRatio,
|
|
23
|
-
} from './util'
|
|
24
|
+
} from './util.js'
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* 10 minutes
|
package/src/dao/bucket.dao.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
|
+
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
|
+
import type { Saved } from '@naturalcycles/js-lib'
|
|
4
|
+
import type { BaseBucket, Bucket } from '../types.js'
|
|
4
5
|
|
|
5
6
|
type BucketDBM = Saved<BaseBucket> & {
|
|
6
7
|
data: string | null
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
|
+
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
|
+
import type { IsoDate, Saved } from '@naturalcycles/js-lib'
|
|
4
|
+
import { localDate } from '@naturalcycles/js-lib'
|
|
5
|
+
import type { BaseExperiment, Experiment } from '../types.js'
|
|
4
6
|
|
|
5
7
|
type ExperimentDBM = Saved<BaseExperiment> & {
|
|
6
8
|
rules: string | null
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
|
+
import { CommonDao } from '@naturalcycles/db-lib'
|
|
3
|
+
import type { UserAssignment } from '../types.js'
|
|
3
4
|
|
|
4
5
|
export class UserAssignmentDao extends CommonDao<UserAssignment> {}
|
|
5
6
|
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './abba'
|
|
2
|
-
export * from './types'
|
|
1
|
+
export * from './abba.js'
|
|
2
|
+
export * from './types.js'
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CommonDB } from '@naturalcycles/db-lib'
|
|
2
|
-
import { AnyObject, BaseDBEntity, IsoDate, Saved } from '@naturalcycles/js-lib'
|
|
1
|
+
import type { CommonDB } from '@naturalcycles/db-lib'
|
|
2
|
+
import type { AnyObject, BaseDBEntity, IsoDate, Saved } from '@naturalcycles/js-lib'
|
|
3
3
|
|
|
4
4
|
export interface AbbaConfig {
|
|
5
5
|
db: CommonDB
|
package/src/util.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Unsaved } from '@naturalcycles/js-lib'
|
|
2
|
+
import { localDate } from '@naturalcycles/js-lib'
|
|
2
3
|
import { satisfies } from 'semver'
|
|
3
|
-
import {
|
|
4
|
-
AssignmentStatus,
|
|
4
|
+
import type {
|
|
5
5
|
Bucket,
|
|
6
6
|
ExclusionSet,
|
|
7
7
|
Experiment,
|
|
@@ -9,9 +9,9 @@ import {
|
|
|
9
9
|
SegmentationData,
|
|
10
10
|
SegmentationRule,
|
|
11
11
|
SegmentationRuleFn,
|
|
12
|
-
SegmentationRuleOperator,
|
|
13
12
|
UserAssignment,
|
|
14
|
-
} from './types'
|
|
13
|
+
} from './types.js'
|
|
14
|
+
import { AssignmentStatus, SegmentationRuleOperator } from './types.js'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Generate a new assignment for a given user.
|
|
@@ -34,19 +34,23 @@ export const generateUserAssignmentData = (
|
|
|
34
34
|
} as UserAssignment
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
class RandomService {
|
|
38
|
+
/**
|
|
39
|
+
* Generate a random number between 0 and 100
|
|
40
|
+
*/
|
|
41
|
+
rollDie(): number {
|
|
42
|
+
return Math.random() * 100
|
|
43
|
+
}
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
export const randomService = new RandomService()
|
|
47
|
+
|
|
44
48
|
/**
|
|
45
49
|
* Determines a users assignment for this experiment. Returns null if they are not considered to be in the sampling group
|
|
46
50
|
*/
|
|
47
51
|
export const determineAssignment = (sampling: number, buckets: Bucket[]): Bucket | null => {
|
|
48
52
|
// Should this person be considered for the experiment?
|
|
49
|
-
if (rollDie() > sampling) {
|
|
53
|
+
if (randomService.rollDie() > sampling) {
|
|
50
54
|
return null
|
|
51
55
|
}
|
|
52
56
|
|
|
@@ -58,7 +62,7 @@ export const determineAssignment = (sampling: number, buckets: Bucket[]): Bucket
|
|
|
58
62
|
* Determines which bucket a user assignment will recieve
|
|
59
63
|
*/
|
|
60
64
|
export const determineBucket = (buckets: Bucket[]): Bucket => {
|
|
61
|
-
const bucketRoll = rollDie()
|
|
65
|
+
const bucketRoll = randomService.rollDie()
|
|
62
66
|
let range: [number, number] | undefined
|
|
63
67
|
const bucket = buckets.find(b => {
|
|
64
68
|
if (!range) {
|