@logg/signals 0.4.0 → 0.5.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 +41 -0
- package/dist/reco.d.mts +26 -1
- package/dist/reco.d.ts +26 -1
- package/dist/reco.js +96 -1
- package/dist/reco.mjs +96 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -542,6 +542,47 @@ with the flag on, `net` / `diagnostics.bestNet` move from unbounded raw power
|
|
|
542
542
|
to roughly `[−Σweight, +Σweight]` (≈ `[−1, 1]` when weights sum to 1), so any
|
|
543
543
|
consumer comparing `bestNet` against a raw-power threshold must rescale.
|
|
544
544
|
|
|
545
|
+
### Feed controls (dual engine) — `0.5.0`
|
|
546
|
+
|
|
547
|
+
Two additive, **default-off** knobs that shape the feed without touching how
|
|
548
|
+
the engine learns:
|
|
549
|
+
|
|
550
|
+
**`seenWindow`** — a sliding "already shown" FIFO. By default the dual engine
|
|
551
|
+
suppresses every item it has shown until a full-page rotation
|
|
552
|
+
(`shuffleThreshold`), so a large catalog yields an all-distinct feed and
|
|
553
|
+
familiar items rarely recur. Set `seenWindow` to let an item become eligible
|
|
554
|
+
again after N other picks:
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
const reco = new DualBucketRecommender(catalog, { schema, seenWindow: 300 });
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
`undefined` / `0` keeps the original behavior. `clearSeen()` / `reset()` clear
|
|
561
|
+
the window.
|
|
562
|
+
|
|
563
|
+
**`diversityCap`** — cap any single value of one feature from dominating the
|
|
564
|
+
feed. Best-of-K is pure exploit, so the brand with the most inventory + taste
|
|
565
|
+
can swamp the feed. When the top pick's value already holds `maxShare` of the
|
|
566
|
+
recent window, the engine backfills with the best *familiar* under-cap
|
|
567
|
+
candidate instead:
|
|
568
|
+
|
|
569
|
+
```typescript
|
|
570
|
+
const reco = new DualBucketRecommender(catalog, {
|
|
571
|
+
schema,
|
|
572
|
+
diversityCap: { feature: 'brand', maxShare: 0.4, window: 20, familiarOnly: true },
|
|
573
|
+
});
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
- `feature` — schema key to cap (e.g. `'brand'`).
|
|
577
|
+
- `maxShare` — max fraction of the recent window any one value may hold.
|
|
578
|
+
- `window` — picks the share is measured over (default 20).
|
|
579
|
+
- `familiarOnly` — when an over-cap winner can't be replaced by an under-cap
|
|
580
|
+
*familiar* item (one the user has positive signal for), keep it familiar
|
|
581
|
+
rather than inject an unfamiliar item. **Leave this on for taste-driven
|
|
582
|
+
feeds** — backfilling with any item instead drops feed familiarity sharply.
|
|
583
|
+
|
|
584
|
+
`diagnostics.diversitySwapped` reports when a pick was a cap backfill.
|
|
585
|
+
|
|
545
586
|
Advanced primitives (slot tables, interest state, bucket admission, the
|
|
546
587
|
weighted sampler, the dual-bucket `bucketContribution`, and `seededRng` for
|
|
547
588
|
deterministic tests) are exported from the same entry for callers building
|
package/dist/reco.d.mts
CHANGED
|
@@ -158,17 +158,33 @@ declare function describe(state: DualBucketState, top?: number): {
|
|
|
158
158
|
disliked: Record<string, [string, number][]>;
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
+
interface DiversityCap {
|
|
162
|
+
feature: string;
|
|
163
|
+
maxShare: number;
|
|
164
|
+
window?: number;
|
|
165
|
+
familiarOnly?: boolean;
|
|
166
|
+
}
|
|
161
167
|
interface DualRecommenderConfig<T extends BaseItem> {
|
|
162
168
|
schema: Schema<T>;
|
|
163
169
|
sampleSize?: number;
|
|
164
170
|
shuffleThreshold?: number;
|
|
165
171
|
weightedScoring?: boolean;
|
|
172
|
+
seenWindow?: number;
|
|
173
|
+
diversityCap?: DiversityCap;
|
|
166
174
|
rng?: RNG;
|
|
167
175
|
}
|
|
176
|
+
interface ResolvedDiversityCap {
|
|
177
|
+
feature: string;
|
|
178
|
+
maxShare: number;
|
|
179
|
+
window: number;
|
|
180
|
+
familiarOnly: boolean;
|
|
181
|
+
}
|
|
168
182
|
interface ResolvedConfig<T extends BaseItem> {
|
|
169
183
|
interest: DualInterestConfig<T>;
|
|
170
184
|
sampleSize: number;
|
|
171
185
|
shuffleThreshold: number;
|
|
186
|
+
seenWindow: number;
|
|
187
|
+
diversityCap: ResolvedDiversityCap | null;
|
|
172
188
|
rng: RNG;
|
|
173
189
|
}
|
|
174
190
|
interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
@@ -179,6 +195,7 @@ interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
|
179
195
|
bestLiked: number;
|
|
180
196
|
bestDisliked: number;
|
|
181
197
|
freshPagesIssued: number;
|
|
198
|
+
diversitySwapped: boolean;
|
|
182
199
|
breakdown: ItemScore['byFeature'] | null;
|
|
183
200
|
runnersUp: Array<{
|
|
184
201
|
item: T;
|
|
@@ -199,7 +216,10 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
199
216
|
readonly config: ResolvedConfig<T>;
|
|
200
217
|
private ownedIds;
|
|
201
218
|
private seen;
|
|
219
|
+
private seenOrder;
|
|
202
220
|
private freshPagesIssued;
|
|
221
|
+
private recentValues;
|
|
222
|
+
private valueCounts;
|
|
203
223
|
constructor(catalog: T[], config: DualRecommenderConfig<T>);
|
|
204
224
|
setOwned(ids: Iterable<string>): void;
|
|
205
225
|
setSampleSize(k: number): void;
|
|
@@ -207,6 +227,11 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
207
227
|
engage(item: T, score: number): void;
|
|
208
228
|
recommend(count?: number): DualRecommendResult<T>;
|
|
209
229
|
private pickOne;
|
|
230
|
+
private pickUnderCapReplacement;
|
|
231
|
+
private atCap;
|
|
232
|
+
private isFamiliar;
|
|
233
|
+
private markSeen;
|
|
234
|
+
private recordPick;
|
|
210
235
|
private candidatePool;
|
|
211
236
|
seenCount(): number;
|
|
212
237
|
clearSeen(): void;
|
|
@@ -216,4 +241,4 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
216
241
|
declare function logDecadeBucket(value: number | null, multiplier?: number, prefix?: string): string | null;
|
|
217
242
|
declare function yearBucket(year: number | null, bandSize?: number): string | null;
|
|
218
243
|
|
|
219
|
-
export { type BaseItem, type BucketConfig, type BucketState, DEFAULT_CAPACITY, DEFAULT_DECREMENT_CAP, DEFAULT_EPSILON, DEFAULT_INTEREST_CONFIG, DEFAULT_SLOT_CONFIG, DualBucketRecommender, type DualBucketState, type DualInterestConfig, type ItemScore as DualItemScore, type DualRecommendDiagnostics, type DualRecommendResult, type DualRecommenderConfig, type FeatureConfig, type InterestConfig, type InterestState, type RNG, type RecommendDiagnostics, type RecommendResult, Recommender, type RecommenderConfig, SIGNALS, type Schema, type SignalName, type Slot, type SlotTableConfig, type SlotTableState, admit, applyEvent, contribution as bucketContribution, contribution$1 as contribution, decay, defaultRng, describe$1 as describe, describe as describeDual, emptyBucket, emptyDualState, emptyInterest, emptyState, engageNegative, engagePositive, isCold$1 as isCold, isCold as isColdDual, isEmpty$1 as isEmpty, isEmpty as isEmptyBucket, logDecadeBucket, observe, powerOf, scoreItem$1 as scoreItem, scoreItem as scoreItemDual, seededRng, weightedSampleWithoutReplacement, yearBucket };
|
|
244
|
+
export { type BaseItem, type BucketConfig, type BucketState, DEFAULT_CAPACITY, DEFAULT_DECREMENT_CAP, DEFAULT_EPSILON, DEFAULT_INTEREST_CONFIG, DEFAULT_SLOT_CONFIG, type DiversityCap, DualBucketRecommender, type DualBucketState, type DualInterestConfig, type ItemScore as DualItemScore, type DualRecommendDiagnostics, type DualRecommendResult, type DualRecommenderConfig, type FeatureConfig, type InterestConfig, type InterestState, type RNG, type RecommendDiagnostics, type RecommendResult, Recommender, type RecommenderConfig, SIGNALS, type Schema, type SignalName, type Slot, type SlotTableConfig, type SlotTableState, admit, applyEvent, contribution as bucketContribution, contribution$1 as contribution, decay, defaultRng, describe$1 as describe, describe as describeDual, emptyBucket, emptyDualState, emptyInterest, emptyState, engageNegative, engagePositive, isCold$1 as isCold, isCold as isColdDual, isEmpty$1 as isEmpty, isEmpty as isEmptyBucket, logDecadeBucket, observe, powerOf, scoreItem$1 as scoreItem, scoreItem as scoreItemDual, seededRng, weightedSampleWithoutReplacement, yearBucket };
|
package/dist/reco.d.ts
CHANGED
|
@@ -158,17 +158,33 @@ declare function describe(state: DualBucketState, top?: number): {
|
|
|
158
158
|
disliked: Record<string, [string, number][]>;
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
+
interface DiversityCap {
|
|
162
|
+
feature: string;
|
|
163
|
+
maxShare: number;
|
|
164
|
+
window?: number;
|
|
165
|
+
familiarOnly?: boolean;
|
|
166
|
+
}
|
|
161
167
|
interface DualRecommenderConfig<T extends BaseItem> {
|
|
162
168
|
schema: Schema<T>;
|
|
163
169
|
sampleSize?: number;
|
|
164
170
|
shuffleThreshold?: number;
|
|
165
171
|
weightedScoring?: boolean;
|
|
172
|
+
seenWindow?: number;
|
|
173
|
+
diversityCap?: DiversityCap;
|
|
166
174
|
rng?: RNG;
|
|
167
175
|
}
|
|
176
|
+
interface ResolvedDiversityCap {
|
|
177
|
+
feature: string;
|
|
178
|
+
maxShare: number;
|
|
179
|
+
window: number;
|
|
180
|
+
familiarOnly: boolean;
|
|
181
|
+
}
|
|
168
182
|
interface ResolvedConfig<T extends BaseItem> {
|
|
169
183
|
interest: DualInterestConfig<T>;
|
|
170
184
|
sampleSize: number;
|
|
171
185
|
shuffleThreshold: number;
|
|
186
|
+
seenWindow: number;
|
|
187
|
+
diversityCap: ResolvedDiversityCap | null;
|
|
172
188
|
rng: RNG;
|
|
173
189
|
}
|
|
174
190
|
interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
@@ -179,6 +195,7 @@ interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
|
179
195
|
bestLiked: number;
|
|
180
196
|
bestDisliked: number;
|
|
181
197
|
freshPagesIssued: number;
|
|
198
|
+
diversitySwapped: boolean;
|
|
182
199
|
breakdown: ItemScore['byFeature'] | null;
|
|
183
200
|
runnersUp: Array<{
|
|
184
201
|
item: T;
|
|
@@ -199,7 +216,10 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
199
216
|
readonly config: ResolvedConfig<T>;
|
|
200
217
|
private ownedIds;
|
|
201
218
|
private seen;
|
|
219
|
+
private seenOrder;
|
|
202
220
|
private freshPagesIssued;
|
|
221
|
+
private recentValues;
|
|
222
|
+
private valueCounts;
|
|
203
223
|
constructor(catalog: T[], config: DualRecommenderConfig<T>);
|
|
204
224
|
setOwned(ids: Iterable<string>): void;
|
|
205
225
|
setSampleSize(k: number): void;
|
|
@@ -207,6 +227,11 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
207
227
|
engage(item: T, score: number): void;
|
|
208
228
|
recommend(count?: number): DualRecommendResult<T>;
|
|
209
229
|
private pickOne;
|
|
230
|
+
private pickUnderCapReplacement;
|
|
231
|
+
private atCap;
|
|
232
|
+
private isFamiliar;
|
|
233
|
+
private markSeen;
|
|
234
|
+
private recordPick;
|
|
210
235
|
private candidatePool;
|
|
211
236
|
seenCount(): number;
|
|
212
237
|
clearSeen(): void;
|
|
@@ -216,4 +241,4 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
216
241
|
declare function logDecadeBucket(value: number | null, multiplier?: number, prefix?: string): string | null;
|
|
217
242
|
declare function yearBucket(year: number | null, bandSize?: number): string | null;
|
|
218
243
|
|
|
219
|
-
export { type BaseItem, type BucketConfig, type BucketState, DEFAULT_CAPACITY, DEFAULT_DECREMENT_CAP, DEFAULT_EPSILON, DEFAULT_INTEREST_CONFIG, DEFAULT_SLOT_CONFIG, DualBucketRecommender, type DualBucketState, type DualInterestConfig, type ItemScore as DualItemScore, type DualRecommendDiagnostics, type DualRecommendResult, type DualRecommenderConfig, type FeatureConfig, type InterestConfig, type InterestState, type RNG, type RecommendDiagnostics, type RecommendResult, Recommender, type RecommenderConfig, SIGNALS, type Schema, type SignalName, type Slot, type SlotTableConfig, type SlotTableState, admit, applyEvent, contribution as bucketContribution, contribution$1 as contribution, decay, defaultRng, describe$1 as describe, describe as describeDual, emptyBucket, emptyDualState, emptyInterest, emptyState, engageNegative, engagePositive, isCold$1 as isCold, isCold as isColdDual, isEmpty$1 as isEmpty, isEmpty as isEmptyBucket, logDecadeBucket, observe, powerOf, scoreItem$1 as scoreItem, scoreItem as scoreItemDual, seededRng, weightedSampleWithoutReplacement, yearBucket };
|
|
244
|
+
export { type BaseItem, type BucketConfig, type BucketState, DEFAULT_CAPACITY, DEFAULT_DECREMENT_CAP, DEFAULT_EPSILON, DEFAULT_INTEREST_CONFIG, DEFAULT_SLOT_CONFIG, type DiversityCap, DualBucketRecommender, type DualBucketState, type DualInterestConfig, type ItemScore as DualItemScore, type DualRecommendDiagnostics, type DualRecommendResult, type DualRecommenderConfig, type FeatureConfig, type InterestConfig, type InterestState, type RNG, type RecommendDiagnostics, type RecommendResult, Recommender, type RecommenderConfig, SIGNALS, type Schema, type SignalName, type Slot, type SlotTableConfig, type SlotTableState, admit, applyEvent, contribution as bucketContribution, contribution$1 as contribution, decay, defaultRng, describe$1 as describe, describe as describeDual, emptyBucket, emptyDualState, emptyInterest, emptyState, engageNegative, engagePositive, isCold$1 as isCold, isCold as isColdDual, isEmpty$1 as isEmpty, isEmpty as isEmptyBucket, logDecadeBucket, observe, powerOf, scoreItem$1 as scoreItem, scoreItem as scoreItemDual, seededRng, weightedSampleWithoutReplacement, yearBucket };
|
package/dist/reco.js
CHANGED
|
@@ -503,6 +503,7 @@ function describe2(state, top = 4) {
|
|
|
503
503
|
}
|
|
504
504
|
|
|
505
505
|
// src/reco/dual-bucket/recommender.ts
|
|
506
|
+
var DEFAULT_DIVERSITY_WINDOW = 20;
|
|
506
507
|
function resolve2(input) {
|
|
507
508
|
return {
|
|
508
509
|
interest: { schema: input.schema, weightedScoring: input.weightedScoring ?? false },
|
|
@@ -510,6 +511,13 @@ function resolve2(input) {
|
|
|
510
511
|
// weak. Live-tunable via setSampleSize().
|
|
511
512
|
sampleSize: input.sampleSize ?? 100,
|
|
512
513
|
shuffleThreshold: input.shuffleThreshold ?? 100,
|
|
514
|
+
seenWindow: input.seenWindow ?? 0,
|
|
515
|
+
diversityCap: input.diversityCap ? {
|
|
516
|
+
feature: input.diversityCap.feature,
|
|
517
|
+
maxShare: input.diversityCap.maxShare,
|
|
518
|
+
window: input.diversityCap.window ?? DEFAULT_DIVERSITY_WINDOW,
|
|
519
|
+
familiarOnly: input.diversityCap.familiarOnly ?? false
|
|
520
|
+
} : null,
|
|
513
521
|
rng: input.rng ?? defaultRng
|
|
514
522
|
};
|
|
515
523
|
}
|
|
@@ -517,7 +525,12 @@ var DualBucketRecommender = class {
|
|
|
517
525
|
constructor(catalog, config) {
|
|
518
526
|
this.ownedIds = /* @__PURE__ */ new Set();
|
|
519
527
|
this.seen = /* @__PURE__ */ new Set();
|
|
528
|
+
this.seenOrder = [];
|
|
520
529
|
this.freshPagesIssued = 0;
|
|
530
|
+
// diversityCap bookkeeping: the capped-feature value of each recent pick,
|
|
531
|
+
// windowed, with a running count per value.
|
|
532
|
+
this.recentValues = [];
|
|
533
|
+
this.valueCounts = /* @__PURE__ */ new Map();
|
|
521
534
|
this.catalog = catalog;
|
|
522
535
|
this.byId = new Map(catalog.map((i) => [i.id, i]));
|
|
523
536
|
this.config = resolve2(config);
|
|
@@ -549,7 +562,8 @@ var DualBucketRecommender = class {
|
|
|
549
562
|
items.push(result.item);
|
|
550
563
|
scores.push(result.diagnostics.bestNet);
|
|
551
564
|
lastDiag = result.diagnostics;
|
|
552
|
-
this.
|
|
565
|
+
this.markSeen(result.item.id);
|
|
566
|
+
this.recordPick(result.item);
|
|
553
567
|
}
|
|
554
568
|
return {
|
|
555
569
|
items,
|
|
@@ -562,6 +576,7 @@ var DualBucketRecommender = class {
|
|
|
562
576
|
bestLiked: 0,
|
|
563
577
|
bestDisliked: 0,
|
|
564
578
|
freshPagesIssued: this.freshPagesIssued,
|
|
579
|
+
diversitySwapped: false,
|
|
565
580
|
breakdown: null,
|
|
566
581
|
runnersUp: []
|
|
567
582
|
}
|
|
@@ -571,6 +586,7 @@ var DualBucketRecommender = class {
|
|
|
571
586
|
let pool = this.candidatePool();
|
|
572
587
|
if (pool.length < this.config.shuffleThreshold) {
|
|
573
588
|
this.seen.clear();
|
|
589
|
+
this.seenOrder.length = 0;
|
|
574
590
|
this.freshPagesIssued += 1;
|
|
575
591
|
pool = this.candidatePool();
|
|
576
592
|
}
|
|
@@ -584,6 +600,19 @@ var DualBucketRecommender = class {
|
|
|
584
600
|
const zero = scored.find((x) => x.s.net === 0);
|
|
585
601
|
if (zero) winner = zero;
|
|
586
602
|
}
|
|
603
|
+
let diversitySwapped = false;
|
|
604
|
+
const cap = this.config.diversityCap;
|
|
605
|
+
if (cap) {
|
|
606
|
+
const fc = this.config.interest.schema[cap.feature];
|
|
607
|
+
const winnerValue = fc ? fc.extract(winner.item) : null;
|
|
608
|
+
if (this.atCap(winnerValue)) {
|
|
609
|
+
const replacement = this.pickUnderCapReplacement(scored, winner.item.id, fc);
|
|
610
|
+
if (replacement && replacement.item.id !== winner.item.id) {
|
|
611
|
+
winner = replacement;
|
|
612
|
+
diversitySwapped = true;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
587
616
|
const runnersUp = scored.filter((x) => x.item.id !== winner.item.id).map((x) => ({
|
|
588
617
|
item: x.item,
|
|
589
618
|
net: x.s.net,
|
|
@@ -600,11 +629,71 @@ var DualBucketRecommender = class {
|
|
|
600
629
|
bestLiked: winner.s.liked,
|
|
601
630
|
bestDisliked: winner.s.disliked,
|
|
602
631
|
freshPagesIssued: this.freshPagesIssued,
|
|
632
|
+
diversitySwapped,
|
|
603
633
|
breakdown: winner.s.byFeature,
|
|
604
634
|
runnersUp
|
|
605
635
|
}
|
|
606
636
|
};
|
|
607
637
|
}
|
|
638
|
+
/** Choose a backfill when the top winner's value is over its diversity cap.
|
|
639
|
+
* `scored` is sorted by net desc, so the first match is the best one. */
|
|
640
|
+
pickUnderCapReplacement(scored, winnerId, fc) {
|
|
641
|
+
const cap = this.config.diversityCap;
|
|
642
|
+
if (!cap || !fc) return null;
|
|
643
|
+
const valueOf = (x) => fc.extract(x.item);
|
|
644
|
+
const underCapFamiliar = scored.find(
|
|
645
|
+
(x) => x.item.id !== winnerId && !this.atCap(valueOf(x)) && this.isFamiliar(valueOf(x))
|
|
646
|
+
);
|
|
647
|
+
if (underCapFamiliar) return underCapFamiliar;
|
|
648
|
+
if (cap.familiarOnly) {
|
|
649
|
+
return scored.find((x) => x.item.id !== winnerId && this.isFamiliar(valueOf(x))) ?? null;
|
|
650
|
+
}
|
|
651
|
+
return scored.find((x) => x.item.id !== winnerId && !this.atCap(valueOf(x))) ?? null;
|
|
652
|
+
}
|
|
653
|
+
/** True when `value` already holds >= maxShare of the recent window. Not
|
|
654
|
+
* enforced until the window has enough samples for a share to be
|
|
655
|
+
* meaningful (otherwise the first pick is trivially "100%"). */
|
|
656
|
+
atCap(value) {
|
|
657
|
+
const cap = this.config.diversityCap;
|
|
658
|
+
if (!cap || value == null) return false;
|
|
659
|
+
const n = this.recentValues.length;
|
|
660
|
+
if (n < Math.ceil(1 / cap.maxShare)) return false;
|
|
661
|
+
return (this.valueCounts.get(value) ?? 0) / n >= cap.maxShare;
|
|
662
|
+
}
|
|
663
|
+
/** "User has positive signal for this capped-feature value." The exported
|
|
664
|
+
* powerOf on the liked bucket is the whole familiarity test. */
|
|
665
|
+
isFamiliar(value) {
|
|
666
|
+
const cap = this.config.diversityCap;
|
|
667
|
+
if (!cap || value == null) return false;
|
|
668
|
+
const bucket = this.interest.liked[cap.feature];
|
|
669
|
+
return bucket ? powerOf(bucket, value) > 0 : false;
|
|
670
|
+
}
|
|
671
|
+
markSeen(id) {
|
|
672
|
+
if (this.seen.has(id)) return;
|
|
673
|
+
this.seen.add(id);
|
|
674
|
+
if (this.config.seenWindow <= 0) return;
|
|
675
|
+
this.seenOrder.push(id);
|
|
676
|
+
while (this.seenOrder.length > this.config.seenWindow) {
|
|
677
|
+
const evicted = this.seenOrder.shift();
|
|
678
|
+
if (evicted != null) this.seen.delete(evicted);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
recordPick(item) {
|
|
682
|
+
const cap = this.config.diversityCap;
|
|
683
|
+
if (!cap) return;
|
|
684
|
+
const fc = this.config.interest.schema[cap.feature];
|
|
685
|
+
const value = fc ? fc.extract(item) : null;
|
|
686
|
+
if (value == null) return;
|
|
687
|
+
this.recentValues.push(value);
|
|
688
|
+
this.valueCounts.set(value, (this.valueCounts.get(value) ?? 0) + 1);
|
|
689
|
+
while (this.recentValues.length > cap.window) {
|
|
690
|
+
const old = this.recentValues.shift();
|
|
691
|
+
if (old == null) continue;
|
|
692
|
+
const c = (this.valueCounts.get(old) ?? 0) - 1;
|
|
693
|
+
if (c <= 0) this.valueCounts.delete(old);
|
|
694
|
+
else this.valueCounts.set(old, c);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
608
697
|
candidatePool() {
|
|
609
698
|
return this.catalog.filter((i) => !this.ownedIds.has(i.id) && !this.seen.has(i.id));
|
|
610
699
|
}
|
|
@@ -613,6 +702,9 @@ var DualBucketRecommender = class {
|
|
|
613
702
|
}
|
|
614
703
|
clearSeen() {
|
|
615
704
|
this.seen.clear();
|
|
705
|
+
this.seenOrder.length = 0;
|
|
706
|
+
this.recentValues.length = 0;
|
|
707
|
+
this.valueCounts.clear();
|
|
616
708
|
this.freshPagesIssued = 0;
|
|
617
709
|
}
|
|
618
710
|
reset() {
|
|
@@ -621,6 +713,9 @@ var DualBucketRecommender = class {
|
|
|
621
713
|
this.interest.disliked = fresh.disliked;
|
|
622
714
|
this.interest.totalEvents = 0;
|
|
623
715
|
this.seen.clear();
|
|
716
|
+
this.seenOrder.length = 0;
|
|
717
|
+
this.recentValues.length = 0;
|
|
718
|
+
this.valueCounts.clear();
|
|
624
719
|
this.freshPagesIssued = 0;
|
|
625
720
|
}
|
|
626
721
|
};
|
package/dist/reco.mjs
CHANGED
|
@@ -446,6 +446,7 @@ function describe2(state, top = 4) {
|
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
// src/reco/dual-bucket/recommender.ts
|
|
449
|
+
var DEFAULT_DIVERSITY_WINDOW = 20;
|
|
449
450
|
function resolve2(input) {
|
|
450
451
|
return {
|
|
451
452
|
interest: { schema: input.schema, weightedScoring: input.weightedScoring ?? false },
|
|
@@ -453,6 +454,13 @@ function resolve2(input) {
|
|
|
453
454
|
// weak. Live-tunable via setSampleSize().
|
|
454
455
|
sampleSize: input.sampleSize ?? 100,
|
|
455
456
|
shuffleThreshold: input.shuffleThreshold ?? 100,
|
|
457
|
+
seenWindow: input.seenWindow ?? 0,
|
|
458
|
+
diversityCap: input.diversityCap ? {
|
|
459
|
+
feature: input.diversityCap.feature,
|
|
460
|
+
maxShare: input.diversityCap.maxShare,
|
|
461
|
+
window: input.diversityCap.window ?? DEFAULT_DIVERSITY_WINDOW,
|
|
462
|
+
familiarOnly: input.diversityCap.familiarOnly ?? false
|
|
463
|
+
} : null,
|
|
456
464
|
rng: input.rng ?? defaultRng
|
|
457
465
|
};
|
|
458
466
|
}
|
|
@@ -460,7 +468,12 @@ var DualBucketRecommender = class {
|
|
|
460
468
|
constructor(catalog, config) {
|
|
461
469
|
this.ownedIds = /* @__PURE__ */ new Set();
|
|
462
470
|
this.seen = /* @__PURE__ */ new Set();
|
|
471
|
+
this.seenOrder = [];
|
|
463
472
|
this.freshPagesIssued = 0;
|
|
473
|
+
// diversityCap bookkeeping: the capped-feature value of each recent pick,
|
|
474
|
+
// windowed, with a running count per value.
|
|
475
|
+
this.recentValues = [];
|
|
476
|
+
this.valueCounts = /* @__PURE__ */ new Map();
|
|
464
477
|
this.catalog = catalog;
|
|
465
478
|
this.byId = new Map(catalog.map((i) => [i.id, i]));
|
|
466
479
|
this.config = resolve2(config);
|
|
@@ -492,7 +505,8 @@ var DualBucketRecommender = class {
|
|
|
492
505
|
items.push(result.item);
|
|
493
506
|
scores.push(result.diagnostics.bestNet);
|
|
494
507
|
lastDiag = result.diagnostics;
|
|
495
|
-
this.
|
|
508
|
+
this.markSeen(result.item.id);
|
|
509
|
+
this.recordPick(result.item);
|
|
496
510
|
}
|
|
497
511
|
return {
|
|
498
512
|
items,
|
|
@@ -505,6 +519,7 @@ var DualBucketRecommender = class {
|
|
|
505
519
|
bestLiked: 0,
|
|
506
520
|
bestDisliked: 0,
|
|
507
521
|
freshPagesIssued: this.freshPagesIssued,
|
|
522
|
+
diversitySwapped: false,
|
|
508
523
|
breakdown: null,
|
|
509
524
|
runnersUp: []
|
|
510
525
|
}
|
|
@@ -514,6 +529,7 @@ var DualBucketRecommender = class {
|
|
|
514
529
|
let pool = this.candidatePool();
|
|
515
530
|
if (pool.length < this.config.shuffleThreshold) {
|
|
516
531
|
this.seen.clear();
|
|
532
|
+
this.seenOrder.length = 0;
|
|
517
533
|
this.freshPagesIssued += 1;
|
|
518
534
|
pool = this.candidatePool();
|
|
519
535
|
}
|
|
@@ -527,6 +543,19 @@ var DualBucketRecommender = class {
|
|
|
527
543
|
const zero = scored.find((x) => x.s.net === 0);
|
|
528
544
|
if (zero) winner = zero;
|
|
529
545
|
}
|
|
546
|
+
let diversitySwapped = false;
|
|
547
|
+
const cap = this.config.diversityCap;
|
|
548
|
+
if (cap) {
|
|
549
|
+
const fc = this.config.interest.schema[cap.feature];
|
|
550
|
+
const winnerValue = fc ? fc.extract(winner.item) : null;
|
|
551
|
+
if (this.atCap(winnerValue)) {
|
|
552
|
+
const replacement = this.pickUnderCapReplacement(scored, winner.item.id, fc);
|
|
553
|
+
if (replacement && replacement.item.id !== winner.item.id) {
|
|
554
|
+
winner = replacement;
|
|
555
|
+
diversitySwapped = true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
530
559
|
const runnersUp = scored.filter((x) => x.item.id !== winner.item.id).map((x) => ({
|
|
531
560
|
item: x.item,
|
|
532
561
|
net: x.s.net,
|
|
@@ -543,11 +572,71 @@ var DualBucketRecommender = class {
|
|
|
543
572
|
bestLiked: winner.s.liked,
|
|
544
573
|
bestDisliked: winner.s.disliked,
|
|
545
574
|
freshPagesIssued: this.freshPagesIssued,
|
|
575
|
+
diversitySwapped,
|
|
546
576
|
breakdown: winner.s.byFeature,
|
|
547
577
|
runnersUp
|
|
548
578
|
}
|
|
549
579
|
};
|
|
550
580
|
}
|
|
581
|
+
/** Choose a backfill when the top winner's value is over its diversity cap.
|
|
582
|
+
* `scored` is sorted by net desc, so the first match is the best one. */
|
|
583
|
+
pickUnderCapReplacement(scored, winnerId, fc) {
|
|
584
|
+
const cap = this.config.diversityCap;
|
|
585
|
+
if (!cap || !fc) return null;
|
|
586
|
+
const valueOf = (x) => fc.extract(x.item);
|
|
587
|
+
const underCapFamiliar = scored.find(
|
|
588
|
+
(x) => x.item.id !== winnerId && !this.atCap(valueOf(x)) && this.isFamiliar(valueOf(x))
|
|
589
|
+
);
|
|
590
|
+
if (underCapFamiliar) return underCapFamiliar;
|
|
591
|
+
if (cap.familiarOnly) {
|
|
592
|
+
return scored.find((x) => x.item.id !== winnerId && this.isFamiliar(valueOf(x))) ?? null;
|
|
593
|
+
}
|
|
594
|
+
return scored.find((x) => x.item.id !== winnerId && !this.atCap(valueOf(x))) ?? null;
|
|
595
|
+
}
|
|
596
|
+
/** True when `value` already holds >= maxShare of the recent window. Not
|
|
597
|
+
* enforced until the window has enough samples for a share to be
|
|
598
|
+
* meaningful (otherwise the first pick is trivially "100%"). */
|
|
599
|
+
atCap(value) {
|
|
600
|
+
const cap = this.config.diversityCap;
|
|
601
|
+
if (!cap || value == null) return false;
|
|
602
|
+
const n = this.recentValues.length;
|
|
603
|
+
if (n < Math.ceil(1 / cap.maxShare)) return false;
|
|
604
|
+
return (this.valueCounts.get(value) ?? 0) / n >= cap.maxShare;
|
|
605
|
+
}
|
|
606
|
+
/** "User has positive signal for this capped-feature value." The exported
|
|
607
|
+
* powerOf on the liked bucket is the whole familiarity test. */
|
|
608
|
+
isFamiliar(value) {
|
|
609
|
+
const cap = this.config.diversityCap;
|
|
610
|
+
if (!cap || value == null) return false;
|
|
611
|
+
const bucket = this.interest.liked[cap.feature];
|
|
612
|
+
return bucket ? powerOf(bucket, value) > 0 : false;
|
|
613
|
+
}
|
|
614
|
+
markSeen(id) {
|
|
615
|
+
if (this.seen.has(id)) return;
|
|
616
|
+
this.seen.add(id);
|
|
617
|
+
if (this.config.seenWindow <= 0) return;
|
|
618
|
+
this.seenOrder.push(id);
|
|
619
|
+
while (this.seenOrder.length > this.config.seenWindow) {
|
|
620
|
+
const evicted = this.seenOrder.shift();
|
|
621
|
+
if (evicted != null) this.seen.delete(evicted);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
recordPick(item) {
|
|
625
|
+
const cap = this.config.diversityCap;
|
|
626
|
+
if (!cap) return;
|
|
627
|
+
const fc = this.config.interest.schema[cap.feature];
|
|
628
|
+
const value = fc ? fc.extract(item) : null;
|
|
629
|
+
if (value == null) return;
|
|
630
|
+
this.recentValues.push(value);
|
|
631
|
+
this.valueCounts.set(value, (this.valueCounts.get(value) ?? 0) + 1);
|
|
632
|
+
while (this.recentValues.length > cap.window) {
|
|
633
|
+
const old = this.recentValues.shift();
|
|
634
|
+
if (old == null) continue;
|
|
635
|
+
const c = (this.valueCounts.get(old) ?? 0) - 1;
|
|
636
|
+
if (c <= 0) this.valueCounts.delete(old);
|
|
637
|
+
else this.valueCounts.set(old, c);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
551
640
|
candidatePool() {
|
|
552
641
|
return this.catalog.filter((i) => !this.ownedIds.has(i.id) && !this.seen.has(i.id));
|
|
553
642
|
}
|
|
@@ -556,6 +645,9 @@ var DualBucketRecommender = class {
|
|
|
556
645
|
}
|
|
557
646
|
clearSeen() {
|
|
558
647
|
this.seen.clear();
|
|
648
|
+
this.seenOrder.length = 0;
|
|
649
|
+
this.recentValues.length = 0;
|
|
650
|
+
this.valueCounts.clear();
|
|
559
651
|
this.freshPagesIssued = 0;
|
|
560
652
|
}
|
|
561
653
|
reset() {
|
|
@@ -564,6 +656,9 @@ var DualBucketRecommender = class {
|
|
|
564
656
|
this.interest.disliked = fresh.disliked;
|
|
565
657
|
this.interest.totalEvents = 0;
|
|
566
658
|
this.seen.clear();
|
|
659
|
+
this.seenOrder.length = 0;
|
|
660
|
+
this.recentValues.length = 0;
|
|
661
|
+
this.valueCounts.clear();
|
|
567
662
|
this.freshPagesIssued = 0;
|
|
568
663
|
}
|
|
569
664
|
};
|