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