@logg/signals 0.2.0 → 0.4.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/README.md +94 -2
- package/dist/chunk-Y6FXYEAI.mjs +10 -0
- package/dist/index.js +3 -3
- package/dist/index.mjs +3 -6
- package/dist/reco.d.mts +219 -0
- package/dist/reco.d.ts +219 -0
- package/dist/reco.js +686 -0
- package/dist/reco.mjs +628 -0
- package/package.json +11 -4
package/dist/reco.mjs
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import "./chunk-Y6FXYEAI.mjs";
|
|
2
|
+
|
|
3
|
+
// src/reco/types.ts
|
|
4
|
+
var DEFAULT_CAPACITY = 3;
|
|
5
|
+
var DEFAULT_DECREMENT_CAP = 4;
|
|
6
|
+
var DEFAULT_EPSILON = 0.1;
|
|
7
|
+
var SIGNALS = {
|
|
8
|
+
view: 0.5,
|
|
9
|
+
click: 2,
|
|
10
|
+
dwell: 3,
|
|
11
|
+
collect: 8,
|
|
12
|
+
skip: -1
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// src/reco/slots.ts
|
|
16
|
+
var DEFAULT_SLOT_CONFIG = {
|
|
17
|
+
capacity: DEFAULT_CAPACITY,
|
|
18
|
+
challengerCapacity: DEFAULT_CAPACITY * 2,
|
|
19
|
+
epsilon: DEFAULT_EPSILON
|
|
20
|
+
};
|
|
21
|
+
function emptyState() {
|
|
22
|
+
return { slots: [], challengers: [] };
|
|
23
|
+
}
|
|
24
|
+
function reorder(arr) {
|
|
25
|
+
arr.sort((a, b) => b.power - a.power || a.value.localeCompare(b.value));
|
|
26
|
+
}
|
|
27
|
+
function observe(state, value, weight, config = DEFAULT_SLOT_CONFIG) {
|
|
28
|
+
if (!value) return;
|
|
29
|
+
const slot = state.slots.find((s) => s.value === value);
|
|
30
|
+
if (slot) {
|
|
31
|
+
slot.power = Math.max(0, slot.power + weight);
|
|
32
|
+
if (slot.power === 0) {
|
|
33
|
+
state.slots = state.slots.filter((s) => s !== slot);
|
|
34
|
+
}
|
|
35
|
+
reorder(state.slots);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (weight <= 0) return;
|
|
39
|
+
const challenger = state.challengers.find((c) => c.value === value);
|
|
40
|
+
if (challenger) {
|
|
41
|
+
challenger.power += weight;
|
|
42
|
+
} else {
|
|
43
|
+
state.challengers.push({ value, power: weight });
|
|
44
|
+
}
|
|
45
|
+
reorder(state.challengers);
|
|
46
|
+
if (state.challengers.length > config.challengerCapacity) {
|
|
47
|
+
state.challengers.length = config.challengerCapacity;
|
|
48
|
+
}
|
|
49
|
+
if (state.slots.length < config.capacity) {
|
|
50
|
+
const top2 = state.challengers.shift();
|
|
51
|
+
if (top2) {
|
|
52
|
+
state.slots.push(top2);
|
|
53
|
+
reorder(state.slots);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const top = state.challengers[0];
|
|
58
|
+
const weakest = state.slots[state.slots.length - 1];
|
|
59
|
+
if (top && weakest && top.power > weakest.power) {
|
|
60
|
+
state.slots[state.slots.length - 1] = top;
|
|
61
|
+
state.challengers[0] = weakest;
|
|
62
|
+
reorder(state.slots);
|
|
63
|
+
reorder(state.challengers);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function decay(state, factor) {
|
|
67
|
+
if (factor <= 0 || factor >= 1) return;
|
|
68
|
+
for (const s of state.slots) s.power *= factor;
|
|
69
|
+
for (const c of state.challengers) c.power *= factor;
|
|
70
|
+
state.slots = state.slots.filter((s) => s.power > 0.01);
|
|
71
|
+
state.challengers = state.challengers.filter((c) => c.power > 0.01);
|
|
72
|
+
reorder(state.slots);
|
|
73
|
+
reorder(state.challengers);
|
|
74
|
+
}
|
|
75
|
+
function contribution(state, value, config = DEFAULT_SLOT_CONFIG) {
|
|
76
|
+
if (!value) return config.epsilon;
|
|
77
|
+
const slot = state.slots.find((s) => s.value === value);
|
|
78
|
+
if (!slot) return config.epsilon;
|
|
79
|
+
const total = state.slots.reduce((sum, s) => sum + s.power, 0);
|
|
80
|
+
if (total <= 0) return config.epsilon;
|
|
81
|
+
return config.epsilon + (1 - config.epsilon) * (slot.power / total);
|
|
82
|
+
}
|
|
83
|
+
function isEmpty(state) {
|
|
84
|
+
return state.slots.length === 0 && state.challengers.length === 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/reco/interest.ts
|
|
88
|
+
var DEFAULT_INTEREST_CONFIG = {
|
|
89
|
+
popularityWeight: 0.1,
|
|
90
|
+
decayFactor: 0.97,
|
|
91
|
+
decayEvery: 25
|
|
92
|
+
};
|
|
93
|
+
function emptyInterest(schema) {
|
|
94
|
+
const tables = {};
|
|
95
|
+
for (const k of Object.keys(schema)) tables[k] = emptyState();
|
|
96
|
+
return { tables, eventsSinceDecay: 0, totalEvents: 0 };
|
|
97
|
+
}
|
|
98
|
+
function tableConfig(fc) {
|
|
99
|
+
const capacity = fc.capacity ?? DEFAULT_CAPACITY;
|
|
100
|
+
return {
|
|
101
|
+
capacity,
|
|
102
|
+
challengerCapacity: fc.challengerCapacity ?? capacity * 2,
|
|
103
|
+
epsilon: fc.epsilon ?? DEFAULT_EPSILON
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function featureWeight(fc, config, featureCount) {
|
|
107
|
+
if (typeof fc.weight === "number") return fc.weight;
|
|
108
|
+
const budget = Math.max(0, 1 - config.popularityWeight);
|
|
109
|
+
return featureCount > 0 ? budget / featureCount : 0;
|
|
110
|
+
}
|
|
111
|
+
function applyEvent(state, item, magnitude, config) {
|
|
112
|
+
if (!Number.isFinite(magnitude) || magnitude === 0) return;
|
|
113
|
+
for (const [key, fc] of Object.entries(config.schema)) {
|
|
114
|
+
const value = fc.extract(item);
|
|
115
|
+
if (!value) continue;
|
|
116
|
+
observe(state.tables[key], value, magnitude, tableConfig(fc));
|
|
117
|
+
}
|
|
118
|
+
state.totalEvents += 1;
|
|
119
|
+
state.eventsSinceDecay += 1;
|
|
120
|
+
if (state.eventsSinceDecay >= config.decayEvery) {
|
|
121
|
+
for (const key of Object.keys(config.schema)) decay(state.tables[key], config.decayFactor);
|
|
122
|
+
state.eventsSinceDecay = 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function scoreItem(state, item, popularityNorm, config) {
|
|
126
|
+
const entries = Object.entries(config.schema);
|
|
127
|
+
let score = config.popularityWeight * popularityNorm;
|
|
128
|
+
for (const [key, fc] of entries) {
|
|
129
|
+
const value = fc.extract(item);
|
|
130
|
+
const c = contribution(state.tables[key], value, tableConfig(fc));
|
|
131
|
+
score += featureWeight(fc, config, entries.length) * c;
|
|
132
|
+
}
|
|
133
|
+
return score;
|
|
134
|
+
}
|
|
135
|
+
function isCold(state) {
|
|
136
|
+
for (const t of Object.values(state.tables)) {
|
|
137
|
+
if (!isEmpty(t)) return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
function describe(state, top = 3) {
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const [key, t] of Object.entries(state.tables)) {
|
|
144
|
+
out[key] = t.slots.slice(0, top).map((s) => [s.value, Math.round(s.power * 100) / 100]);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/reco/sample.ts
|
|
150
|
+
var defaultRng = Math.random;
|
|
151
|
+
function weightedSampleWithoutReplacement(pool, weight, count, rng = defaultRng) {
|
|
152
|
+
if (count <= 0 || pool.length === 0) return [];
|
|
153
|
+
const heap = [];
|
|
154
|
+
const heapPush = (e) => {
|
|
155
|
+
heap.push(e);
|
|
156
|
+
let i = heap.length - 1;
|
|
157
|
+
while (i > 0) {
|
|
158
|
+
const parent = i - 1 >> 1;
|
|
159
|
+
if (heap[parent].key <= heap[i].key) break;
|
|
160
|
+
[heap[parent], heap[i]] = [heap[i], heap[parent]];
|
|
161
|
+
i = parent;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const heapPopMin = () => {
|
|
165
|
+
if (heap.length === 0) return void 0;
|
|
166
|
+
const min = heap[0];
|
|
167
|
+
const last = heap.pop();
|
|
168
|
+
if (heap.length > 0) {
|
|
169
|
+
heap[0] = last;
|
|
170
|
+
let i = 0;
|
|
171
|
+
while (true) {
|
|
172
|
+
const l = 2 * i + 1;
|
|
173
|
+
const r = 2 * i + 2;
|
|
174
|
+
let smallest = i;
|
|
175
|
+
if (l < heap.length && heap[l].key < heap[smallest].key) smallest = l;
|
|
176
|
+
if (r < heap.length && heap[r].key < heap[smallest].key) smallest = r;
|
|
177
|
+
if (smallest === i) break;
|
|
178
|
+
[heap[smallest], heap[i]] = [heap[i], heap[smallest]];
|
|
179
|
+
i = smallest;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return min;
|
|
183
|
+
};
|
|
184
|
+
for (const item of pool) {
|
|
185
|
+
const w = Math.max(weight(item), 1e-9);
|
|
186
|
+
const u = Math.max(rng(), 1e-12);
|
|
187
|
+
const key = Math.exp(Math.log(u) / w);
|
|
188
|
+
if (heap.length < count) {
|
|
189
|
+
heapPush({ item, key });
|
|
190
|
+
} else if (heap[0].key < key) {
|
|
191
|
+
heapPopMin();
|
|
192
|
+
heapPush({ item, key });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return heap.sort((a, b) => b.key - a.key).map((e) => e.item);
|
|
196
|
+
}
|
|
197
|
+
function seededRng(seed) {
|
|
198
|
+
let s = seed >>> 0;
|
|
199
|
+
return () => {
|
|
200
|
+
s = s + 1831565813 >>> 0;
|
|
201
|
+
let t = s;
|
|
202
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
203
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
204
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/reco/recommender.ts
|
|
209
|
+
function resolve(input) {
|
|
210
|
+
return {
|
|
211
|
+
interest: {
|
|
212
|
+
schema: input.schema,
|
|
213
|
+
popularity: input.popularity,
|
|
214
|
+
popularityWeight: input.popularityWeight ?? DEFAULT_INTEREST_CONFIG.popularityWeight,
|
|
215
|
+
decayFactor: input.decayFactor ?? DEFAULT_INTEREST_CONFIG.decayFactor,
|
|
216
|
+
decayEvery: input.decayEvery ?? DEFAULT_INTEREST_CONFIG.decayEvery
|
|
217
|
+
},
|
|
218
|
+
popularity: input.popularity ?? null,
|
|
219
|
+
seenWindow: input.seenWindow ?? 300,
|
|
220
|
+
batchSize: input.batchSize ?? 20,
|
|
221
|
+
rng: input.rng ?? defaultRng
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
var Recommender = class {
|
|
225
|
+
constructor(catalog, config) {
|
|
226
|
+
this.ownedIds = /* @__PURE__ */ new Set();
|
|
227
|
+
this.seenOrder = [];
|
|
228
|
+
this.seen = /* @__PURE__ */ new Set();
|
|
229
|
+
this.catalog = catalog;
|
|
230
|
+
this.byId = new Map(catalog.map((i) => [i.id, i]));
|
|
231
|
+
this.config = resolve(config);
|
|
232
|
+
this.interest = emptyInterest(this.config.interest.schema);
|
|
233
|
+
if (this.config.popularity) {
|
|
234
|
+
const pop = this.config.popularity;
|
|
235
|
+
this.maxPopularity = Math.max(1, ...catalog.map((i) => pop(i)));
|
|
236
|
+
} else {
|
|
237
|
+
this.maxPopularity = 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
setOwned(ids) {
|
|
241
|
+
this.ownedIds = new Set(ids);
|
|
242
|
+
}
|
|
243
|
+
/** Apply a positive prior from a list of items the user already
|
|
244
|
+
* "loves" (their collection, recent favourites, etc.). Defaults to
|
|
245
|
+
* `SIGNALS.collect` per item. */
|
|
246
|
+
prime(items, magnitudePerItem = SIGNALS.collect) {
|
|
247
|
+
for (const item of items) this.engage(item, magnitudePerItem);
|
|
248
|
+
}
|
|
249
|
+
engage(item, score) {
|
|
250
|
+
applyEvent(this.interest, item, score, this.config.interest);
|
|
251
|
+
}
|
|
252
|
+
recommend(count = this.config.batchSize) {
|
|
253
|
+
let excluded_seen = 0;
|
|
254
|
+
let excluded_owned = 0;
|
|
255
|
+
const candidates = [];
|
|
256
|
+
for (const item of this.catalog) {
|
|
257
|
+
if (this.ownedIds.has(item.id)) {
|
|
258
|
+
excluded_owned++;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (this.seen.has(item.id)) {
|
|
262
|
+
excluded_seen++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
candidates.push(item);
|
|
266
|
+
}
|
|
267
|
+
const cold = isCold(this.interest);
|
|
268
|
+
const pop = this.config.popularity;
|
|
269
|
+
const popMax = this.maxPopularity;
|
|
270
|
+
const items = weightedSampleWithoutReplacement(
|
|
271
|
+
candidates,
|
|
272
|
+
(item) => {
|
|
273
|
+
const popNorm = pop ? pop(item) / popMax : 0;
|
|
274
|
+
if (cold) {
|
|
275
|
+
return Math.max(0.05, popNorm);
|
|
276
|
+
}
|
|
277
|
+
return scoreItem(this.interest, item, popNorm, this.config.interest);
|
|
278
|
+
},
|
|
279
|
+
count,
|
|
280
|
+
this.config.rng
|
|
281
|
+
);
|
|
282
|
+
const scores = items.map((item) => {
|
|
283
|
+
const popNorm = pop ? pop(item) / popMax : 0;
|
|
284
|
+
return cold ? Math.max(0.05, popNorm) : scoreItem(this.interest, item, popNorm, this.config.interest);
|
|
285
|
+
});
|
|
286
|
+
for (const item of items) this.markSeen(item.id);
|
|
287
|
+
return {
|
|
288
|
+
items,
|
|
289
|
+
scores,
|
|
290
|
+
diagnostics: { cold, candidates: candidates.length, excluded_seen, excluded_owned }
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
seenCount() {
|
|
294
|
+
return this.seenOrder.length;
|
|
295
|
+
}
|
|
296
|
+
/** Clear only the seen FIFO — interest profile + owned set survive. */
|
|
297
|
+
clearSeen() {
|
|
298
|
+
this.seen.clear();
|
|
299
|
+
this.seenOrder.length = 0;
|
|
300
|
+
}
|
|
301
|
+
markSeen(id) {
|
|
302
|
+
if (this.seen.has(id)) return;
|
|
303
|
+
this.seen.add(id);
|
|
304
|
+
this.seenOrder.push(id);
|
|
305
|
+
while (this.seenOrder.length > this.config.seenWindow) {
|
|
306
|
+
const evicted = this.seenOrder.shift();
|
|
307
|
+
if (evicted != null) this.seen.delete(evicted);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Full wipe (interest + seen). */
|
|
311
|
+
reset() {
|
|
312
|
+
const fresh = emptyInterest(this.config.interest.schema);
|
|
313
|
+
this.interest.tables = fresh.tables;
|
|
314
|
+
this.interest.eventsSinceDecay = 0;
|
|
315
|
+
this.interest.totalEvents = 0;
|
|
316
|
+
this.seen.clear();
|
|
317
|
+
this.seenOrder.length = 0;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// src/reco/dual-bucket/buckets.ts
|
|
322
|
+
var FALLBACK_CONFIG = {
|
|
323
|
+
capacity: DEFAULT_CAPACITY,
|
|
324
|
+
decrementCap: DEFAULT_DECREMENT_CAP
|
|
325
|
+
};
|
|
326
|
+
function emptyBucket() {
|
|
327
|
+
return { slots: [], pointer: 0 };
|
|
328
|
+
}
|
|
329
|
+
function admit(state, value, score, config = FALLBACK_CONFIG) {
|
|
330
|
+
if (!value || !Number.isFinite(score) || score <= 0) return;
|
|
331
|
+
const existing = state.slots.find((s) => s.value === value);
|
|
332
|
+
if (existing) {
|
|
333
|
+
existing.power += score;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (state.slots.length < config.capacity) {
|
|
337
|
+
state.slots.push({ value, power: score });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const ptr = state.pointer % state.slots.length;
|
|
341
|
+
const decrement = Math.min(score, config.decrementCap);
|
|
342
|
+
state.slots[ptr].power -= decrement;
|
|
343
|
+
if (state.slots[ptr].power <= 0) {
|
|
344
|
+
state.slots[ptr] = { value, power: score };
|
|
345
|
+
}
|
|
346
|
+
state.pointer = (ptr + 1) % state.slots.length;
|
|
347
|
+
}
|
|
348
|
+
function powerOf(state, value) {
|
|
349
|
+
if (!value) return 0;
|
|
350
|
+
const slot = state.slots.find((s) => s.value === value);
|
|
351
|
+
return slot ? slot.power : 0;
|
|
352
|
+
}
|
|
353
|
+
function contribution2(state, value, epsilon) {
|
|
354
|
+
if (!value) return epsilon;
|
|
355
|
+
const slot = state.slots.find((s) => s.value === value);
|
|
356
|
+
if (!slot) return epsilon;
|
|
357
|
+
const total = state.slots.reduce((sum, s) => sum + s.power, 0);
|
|
358
|
+
if (total <= 0) return epsilon;
|
|
359
|
+
return epsilon + (1 - epsilon) * (slot.power / total);
|
|
360
|
+
}
|
|
361
|
+
function isEmpty2(state) {
|
|
362
|
+
return state.slots.length === 0;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// src/reco/dual-bucket/interest.ts
|
|
366
|
+
function emptyTables(schema) {
|
|
367
|
+
const out = {};
|
|
368
|
+
for (const k of Object.keys(schema)) out[k] = emptyBucket();
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function emptyDualState(schema) {
|
|
372
|
+
return {
|
|
373
|
+
liked: emptyTables(schema),
|
|
374
|
+
disliked: emptyTables(schema),
|
|
375
|
+
totalEvents: 0
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function bucketCfg(fc) {
|
|
379
|
+
return {
|
|
380
|
+
capacity: fc.capacity ?? DEFAULT_CAPACITY,
|
|
381
|
+
decrementCap: fc.decrementCap ?? DEFAULT_DECREMENT_CAP
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function admitAcrossFeatures(state, side, item, magnitude, schema) {
|
|
385
|
+
const tables = state[side];
|
|
386
|
+
for (const [key, fc] of Object.entries(schema)) {
|
|
387
|
+
const value = fc.extract(item);
|
|
388
|
+
if (value) admit(tables[key], value, magnitude, bucketCfg(fc));
|
|
389
|
+
}
|
|
390
|
+
state.totalEvents += 1;
|
|
391
|
+
}
|
|
392
|
+
function engagePositive(state, item, magnitude, config) {
|
|
393
|
+
if (!Number.isFinite(magnitude) || magnitude <= 0) return;
|
|
394
|
+
admitAcrossFeatures(state, "liked", item, magnitude, config.schema);
|
|
395
|
+
}
|
|
396
|
+
function engageNegative(state, item, magnitude, config) {
|
|
397
|
+
if (!Number.isFinite(magnitude) || magnitude <= 0) return;
|
|
398
|
+
admitAcrossFeatures(state, "disliked", item, magnitude, config.schema);
|
|
399
|
+
}
|
|
400
|
+
function featureWeight2(fc, featureCount) {
|
|
401
|
+
if (typeof fc.weight === "number") return fc.weight;
|
|
402
|
+
return featureCount > 0 ? 1 / featureCount : 0;
|
|
403
|
+
}
|
|
404
|
+
function epsilonOf(fc) {
|
|
405
|
+
return fc.epsilon ?? DEFAULT_EPSILON;
|
|
406
|
+
}
|
|
407
|
+
function scoreItem2(state, item, config) {
|
|
408
|
+
let liked = 0;
|
|
409
|
+
let disliked = 0;
|
|
410
|
+
const byFeature = [];
|
|
411
|
+
const entries = Object.entries(config.schema);
|
|
412
|
+
for (const [key, fc] of entries) {
|
|
413
|
+
const value = fc.extract(item);
|
|
414
|
+
let l;
|
|
415
|
+
let d;
|
|
416
|
+
if (config.weightedScoring) {
|
|
417
|
+
const w = featureWeight2(fc, entries.length);
|
|
418
|
+
const eps = epsilonOf(fc);
|
|
419
|
+
l = w * contribution2(state.liked[key], value, eps);
|
|
420
|
+
d = w * contribution2(state.disliked[key], value, eps);
|
|
421
|
+
} else {
|
|
422
|
+
l = powerOf(state.liked[key], value);
|
|
423
|
+
d = powerOf(state.disliked[key], value);
|
|
424
|
+
}
|
|
425
|
+
liked += l;
|
|
426
|
+
disliked += d;
|
|
427
|
+
byFeature.push({ key, value, liked: l, disliked: d });
|
|
428
|
+
}
|
|
429
|
+
return { liked, disliked, net: liked - disliked, byFeature };
|
|
430
|
+
}
|
|
431
|
+
function isCold2(state) {
|
|
432
|
+
if (state.totalEvents > 0) return false;
|
|
433
|
+
for (const t of Object.values(state.liked)) if (!isEmpty2(t)) return false;
|
|
434
|
+
for (const t of Object.values(state.disliked)) if (!isEmpty2(t)) return false;
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
function describe2(state, top = 4) {
|
|
438
|
+
const snap = (side) => {
|
|
439
|
+
const out = {};
|
|
440
|
+
for (const [k, t] of Object.entries(side)) {
|
|
441
|
+
out[k] = t.slots.slice().sort((a, b) => b.power - a.power).slice(0, top).map((s) => [s.value, Math.round(s.power * 100) / 100]);
|
|
442
|
+
}
|
|
443
|
+
return out;
|
|
444
|
+
};
|
|
445
|
+
return { liked: snap(state.liked), disliked: snap(state.disliked) };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/reco/dual-bucket/recommender.ts
|
|
449
|
+
function resolve2(input) {
|
|
450
|
+
return {
|
|
451
|
+
interest: { schema: input.schema, weightedScoring: input.weightedScoring ?? false },
|
|
452
|
+
// 100 is a heavy exploit setting; drop to 20–30 if exploration feels
|
|
453
|
+
// weak. Live-tunable via setSampleSize().
|
|
454
|
+
sampleSize: input.sampleSize ?? 100,
|
|
455
|
+
shuffleThreshold: input.shuffleThreshold ?? 100,
|
|
456
|
+
rng: input.rng ?? defaultRng
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
var DualBucketRecommender = class {
|
|
460
|
+
constructor(catalog, config) {
|
|
461
|
+
this.ownedIds = /* @__PURE__ */ new Set();
|
|
462
|
+
this.seen = /* @__PURE__ */ new Set();
|
|
463
|
+
this.freshPagesIssued = 0;
|
|
464
|
+
this.catalog = catalog;
|
|
465
|
+
this.byId = new Map(catalog.map((i) => [i.id, i]));
|
|
466
|
+
this.config = resolve2(config);
|
|
467
|
+
this.interest = emptyDualState(this.config.interest.schema);
|
|
468
|
+
}
|
|
469
|
+
setOwned(ids) {
|
|
470
|
+
this.ownedIds = new Set(ids);
|
|
471
|
+
}
|
|
472
|
+
/** Live-tune the best-of-K size. */
|
|
473
|
+
setSampleSize(k) {
|
|
474
|
+
if (!Number.isFinite(k) || k < 1) return;
|
|
475
|
+
this.config.sampleSize = Math.floor(k);
|
|
476
|
+
}
|
|
477
|
+
prime(items, magnitudePerItem = SIGNALS.collect) {
|
|
478
|
+
for (const item of items) this.engage(item, magnitudePerItem);
|
|
479
|
+
}
|
|
480
|
+
engage(item, score) {
|
|
481
|
+
if (!Number.isFinite(score) || score === 0) return;
|
|
482
|
+
if (score > 0) engagePositive(this.interest, item, score, this.config.interest);
|
|
483
|
+
else engageNegative(this.interest, item, -score, this.config.interest);
|
|
484
|
+
}
|
|
485
|
+
recommend(count = 1) {
|
|
486
|
+
const items = [];
|
|
487
|
+
const scores = [];
|
|
488
|
+
let lastDiag = null;
|
|
489
|
+
for (let i = 0; i < count; i++) {
|
|
490
|
+
const result = this.pickOne();
|
|
491
|
+
if (!result) break;
|
|
492
|
+
items.push(result.item);
|
|
493
|
+
scores.push(result.diagnostics.bestNet);
|
|
494
|
+
lastDiag = result.diagnostics;
|
|
495
|
+
this.seen.add(result.item.id);
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
items,
|
|
499
|
+
scores,
|
|
500
|
+
diagnostics: lastDiag ?? {
|
|
501
|
+
cold: isCold2(this.interest),
|
|
502
|
+
candidatesPool: 0,
|
|
503
|
+
sampleSize: 0,
|
|
504
|
+
bestNet: 0,
|
|
505
|
+
bestLiked: 0,
|
|
506
|
+
bestDisliked: 0,
|
|
507
|
+
freshPagesIssued: this.freshPagesIssued,
|
|
508
|
+
breakdown: null,
|
|
509
|
+
runnersUp: []
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
pickOne() {
|
|
514
|
+
let pool = this.candidatePool();
|
|
515
|
+
if (pool.length < this.config.shuffleThreshold) {
|
|
516
|
+
this.seen.clear();
|
|
517
|
+
this.freshPagesIssued += 1;
|
|
518
|
+
pool = this.candidatePool();
|
|
519
|
+
}
|
|
520
|
+
if (pool.length === 0) return null;
|
|
521
|
+
const k = Math.min(this.config.sampleSize, pool.length);
|
|
522
|
+
const sample = randomSample(pool, k, this.config.rng);
|
|
523
|
+
const scored = sample.map((item) => ({ item, s: scoreItem2(this.interest, item, this.config.interest) }));
|
|
524
|
+
scored.sort((a, b) => b.s.net - a.s.net);
|
|
525
|
+
let winner = scored[0];
|
|
526
|
+
if (winner.s.net < 0) {
|
|
527
|
+
const zero = scored.find((x) => x.s.net === 0);
|
|
528
|
+
if (zero) winner = zero;
|
|
529
|
+
}
|
|
530
|
+
const runnersUp = scored.filter((x) => x.item.id !== winner.item.id).map((x) => ({
|
|
531
|
+
item: x.item,
|
|
532
|
+
net: x.s.net,
|
|
533
|
+
liked: x.s.liked,
|
|
534
|
+
disliked: x.s.disliked
|
|
535
|
+
}));
|
|
536
|
+
return {
|
|
537
|
+
item: winner.item,
|
|
538
|
+
diagnostics: {
|
|
539
|
+
cold: isCold2(this.interest),
|
|
540
|
+
candidatesPool: pool.length,
|
|
541
|
+
sampleSize: k,
|
|
542
|
+
bestNet: winner.s.net,
|
|
543
|
+
bestLiked: winner.s.liked,
|
|
544
|
+
bestDisliked: winner.s.disliked,
|
|
545
|
+
freshPagesIssued: this.freshPagesIssued,
|
|
546
|
+
breakdown: winner.s.byFeature,
|
|
547
|
+
runnersUp
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
candidatePool() {
|
|
552
|
+
return this.catalog.filter((i) => !this.ownedIds.has(i.id) && !this.seen.has(i.id));
|
|
553
|
+
}
|
|
554
|
+
seenCount() {
|
|
555
|
+
return this.seen.size;
|
|
556
|
+
}
|
|
557
|
+
clearSeen() {
|
|
558
|
+
this.seen.clear();
|
|
559
|
+
this.freshPagesIssued = 0;
|
|
560
|
+
}
|
|
561
|
+
reset() {
|
|
562
|
+
const fresh = emptyDualState(this.config.interest.schema);
|
|
563
|
+
this.interest.liked = fresh.liked;
|
|
564
|
+
this.interest.disliked = fresh.disliked;
|
|
565
|
+
this.interest.totalEvents = 0;
|
|
566
|
+
this.seen.clear();
|
|
567
|
+
this.freshPagesIssued = 0;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
function randomSample(pool, k, rng) {
|
|
571
|
+
if (k >= pool.length) return pool.slice();
|
|
572
|
+
const arr = pool.slice();
|
|
573
|
+
const n = arr.length;
|
|
574
|
+
const out = [];
|
|
575
|
+
for (let i = 0; i < k; i++) {
|
|
576
|
+
const j = i + Math.floor(rng() * (n - i));
|
|
577
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
578
|
+
out.push(arr[i]);
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/reco/buckets.ts
|
|
584
|
+
function logDecadeBucket(value, multiplier = 2, prefix = "lb") {
|
|
585
|
+
if (value == null || value <= 0) return null;
|
|
586
|
+
return `${prefix}${Math.floor(Math.log10(value) * multiplier)}`;
|
|
587
|
+
}
|
|
588
|
+
function yearBucket(year, bandSize = 5) {
|
|
589
|
+
if (year == null || year <= 0 || bandSize <= 0) return null;
|
|
590
|
+
const start = Math.floor(year / bandSize) * bandSize;
|
|
591
|
+
return `${start}-${start + bandSize - 1}`;
|
|
592
|
+
}
|
|
593
|
+
export {
|
|
594
|
+
DEFAULT_CAPACITY,
|
|
595
|
+
DEFAULT_DECREMENT_CAP,
|
|
596
|
+
DEFAULT_EPSILON,
|
|
597
|
+
DEFAULT_INTEREST_CONFIG,
|
|
598
|
+
DEFAULT_SLOT_CONFIG,
|
|
599
|
+
DualBucketRecommender,
|
|
600
|
+
Recommender,
|
|
601
|
+
SIGNALS,
|
|
602
|
+
admit,
|
|
603
|
+
applyEvent,
|
|
604
|
+
contribution2 as bucketContribution,
|
|
605
|
+
contribution,
|
|
606
|
+
decay,
|
|
607
|
+
defaultRng,
|
|
608
|
+
describe,
|
|
609
|
+
describe2 as describeDual,
|
|
610
|
+
emptyBucket,
|
|
611
|
+
emptyDualState,
|
|
612
|
+
emptyInterest,
|
|
613
|
+
emptyState,
|
|
614
|
+
engageNegative,
|
|
615
|
+
engagePositive,
|
|
616
|
+
isCold,
|
|
617
|
+
isCold2 as isColdDual,
|
|
618
|
+
isEmpty,
|
|
619
|
+
isEmpty2 as isEmptyBucket,
|
|
620
|
+
logDecadeBucket,
|
|
621
|
+
observe,
|
|
622
|
+
powerOf,
|
|
623
|
+
scoreItem,
|
|
624
|
+
scoreItem2 as scoreItemDual,
|
|
625
|
+
seededRng,
|
|
626
|
+
weightedSampleWithoutReplacement,
|
|
627
|
+
yearBucket
|
|
628
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logg/signals",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Universal event tracking SDK for Logg Signals",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Universal event tracking SDK for Logg Signals, with an embeddable on-device recommender",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -10,14 +10,19 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.mjs",
|
|
12
12
|
"require": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./reco": {
|
|
15
|
+
"types": "./dist/reco.d.ts",
|
|
16
|
+
"import": "./dist/reco.mjs",
|
|
17
|
+
"require": "./dist/reco.js"
|
|
13
18
|
}
|
|
14
19
|
},
|
|
15
20
|
"files": [
|
|
16
21
|
"dist"
|
|
17
22
|
],
|
|
18
23
|
"scripts": {
|
|
19
|
-
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
20
|
-
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
24
|
+
"build": "tsup --entry.index src/index.ts --entry.reco src/reco/index.ts --format cjs,esm --dts --clean",
|
|
25
|
+
"dev": "tsup --entry.index src/index.ts --entry.reco src/reco/index.ts --format cjs,esm --dts --watch",
|
|
21
26
|
"test": "vitest",
|
|
22
27
|
"test:ui": "vitest --ui",
|
|
23
28
|
"type-check": "tsc --noEmit",
|
|
@@ -30,6 +35,8 @@
|
|
|
30
35
|
"tracking",
|
|
31
36
|
"logg",
|
|
32
37
|
"signals",
|
|
38
|
+
"recommendations",
|
|
39
|
+
"recommender",
|
|
33
40
|
"react-native",
|
|
34
41
|
"web",
|
|
35
42
|
"nodejs"
|