@logg/signals 0.3.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 +64 -2
- package/dist/reco.d.mts +30 -2
- package/dist/reco.d.ts +30 -2
- package/dist/reco.js +127 -5
- package/dist/reco.mjs +126 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -522,9 +522,71 @@ Methods shared by both engines: `prime()`, `engage()`, `recommend()`,
|
|
|
522
522
|
`setOwned()`, `clearSeen()`, `reset()`, `seenCount()`. The dual engine adds
|
|
523
523
|
`setSampleSize(k)` to tune explore vs. exploit.
|
|
524
524
|
|
|
525
|
+
### Weighted scoring (dual engine) — `0.4.0`
|
|
526
|
+
|
|
527
|
+
By default `DualBucketRecommender` ranks candidates by the sum of raw slot
|
|
528
|
+
powers, which ignores each feature's schema `weight` and lets a
|
|
529
|
+
low-cardinality feature (e.g. one dominant `category`) outweigh a
|
|
530
|
+
high-cardinality one (e.g. `brand`) purely by accumulated mass. Pass
|
|
531
|
+
`weightedScoring: true` to instead weight each feature by its schema `weight`
|
|
532
|
+
and normalize a value's power to its share of the bucket (an ε-floored share,
|
|
533
|
+
the same scheme the v1 engine uses):
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
const reco = new DualBucketRecommender(catalog, { schema, weightedScoring: true });
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
This makes schema weights authoritative and removes the cardinality bias. It
|
|
540
|
+
is **off by default** to preserve existing v2 ranking. Note the scale change:
|
|
541
|
+
with the flag on, `net` / `diagnostics.bestNet` move from unbounded raw power
|
|
542
|
+
to roughly `[−Σweight, +Σweight]` (≈ `[−1, 1]` when weights sum to 1), so any
|
|
543
|
+
consumer comparing `bestNet` against a raw-power threshold must rescale.
|
|
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
|
+
|
|
525
586
|
Advanced primitives (slot tables, interest state, bucket admission, the
|
|
526
|
-
weighted sampler, and `seededRng` for
|
|
527
|
-
the same entry for callers building
|
|
587
|
+
weighted sampler, the dual-bucket `bucketContribution`, and `seededRng` for
|
|
588
|
+
deterministic tests) are exported from the same entry for callers building
|
|
589
|
+
custom pipelines or CLIs on top.
|
|
528
590
|
|
|
529
591
|
## License
|
|
530
592
|
|
package/dist/reco.d.mts
CHANGED
|
@@ -39,7 +39,7 @@ interface SlotTableState {
|
|
|
39
39
|
declare function emptyState(): SlotTableState;
|
|
40
40
|
declare function observe(state: SlotTableState, value: string, weight: number, config?: SlotTableConfig): void;
|
|
41
41
|
declare function decay(state: SlotTableState, factor: number): void;
|
|
42
|
-
declare function contribution(state: SlotTableState, value: string | null, config?: SlotTableConfig): number;
|
|
42
|
+
declare function contribution$1(state: SlotTableState, value: string | null, config?: SlotTableConfig): number;
|
|
43
43
|
declare function isEmpty$1(state: SlotTableState): boolean;
|
|
44
44
|
|
|
45
45
|
interface InterestConfig<T extends BaseItem> {
|
|
@@ -125,10 +125,12 @@ interface BucketConfig {
|
|
|
125
125
|
declare function emptyBucket(): BucketState;
|
|
126
126
|
declare function admit(state: BucketState, value: string, score: number, config?: BucketConfig): void;
|
|
127
127
|
declare function powerOf(state: BucketState, value: string | null): number;
|
|
128
|
+
declare function contribution(state: BucketState, value: string | null, epsilon: number): number;
|
|
128
129
|
declare function isEmpty(state: BucketState): boolean;
|
|
129
130
|
|
|
130
131
|
interface DualInterestConfig<T extends BaseItem> {
|
|
131
132
|
schema: Schema<T>;
|
|
133
|
+
weightedScoring?: boolean;
|
|
132
134
|
}
|
|
133
135
|
interface DualBucketState {
|
|
134
136
|
liked: Record<string, BucketState>;
|
|
@@ -156,16 +158,33 @@ declare function describe(state: DualBucketState, top?: number): {
|
|
|
156
158
|
disliked: Record<string, [string, number][]>;
|
|
157
159
|
};
|
|
158
160
|
|
|
161
|
+
interface DiversityCap {
|
|
162
|
+
feature: string;
|
|
163
|
+
maxShare: number;
|
|
164
|
+
window?: number;
|
|
165
|
+
familiarOnly?: boolean;
|
|
166
|
+
}
|
|
159
167
|
interface DualRecommenderConfig<T extends BaseItem> {
|
|
160
168
|
schema: Schema<T>;
|
|
161
169
|
sampleSize?: number;
|
|
162
170
|
shuffleThreshold?: number;
|
|
171
|
+
weightedScoring?: boolean;
|
|
172
|
+
seenWindow?: number;
|
|
173
|
+
diversityCap?: DiversityCap;
|
|
163
174
|
rng?: RNG;
|
|
164
175
|
}
|
|
176
|
+
interface ResolvedDiversityCap {
|
|
177
|
+
feature: string;
|
|
178
|
+
maxShare: number;
|
|
179
|
+
window: number;
|
|
180
|
+
familiarOnly: boolean;
|
|
181
|
+
}
|
|
165
182
|
interface ResolvedConfig<T extends BaseItem> {
|
|
166
183
|
interest: DualInterestConfig<T>;
|
|
167
184
|
sampleSize: number;
|
|
168
185
|
shuffleThreshold: number;
|
|
186
|
+
seenWindow: number;
|
|
187
|
+
diversityCap: ResolvedDiversityCap | null;
|
|
169
188
|
rng: RNG;
|
|
170
189
|
}
|
|
171
190
|
interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
@@ -176,6 +195,7 @@ interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
|
176
195
|
bestLiked: number;
|
|
177
196
|
bestDisliked: number;
|
|
178
197
|
freshPagesIssued: number;
|
|
198
|
+
diversitySwapped: boolean;
|
|
179
199
|
breakdown: ItemScore['byFeature'] | null;
|
|
180
200
|
runnersUp: Array<{
|
|
181
201
|
item: T;
|
|
@@ -196,7 +216,10 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
196
216
|
readonly config: ResolvedConfig<T>;
|
|
197
217
|
private ownedIds;
|
|
198
218
|
private seen;
|
|
219
|
+
private seenOrder;
|
|
199
220
|
private freshPagesIssued;
|
|
221
|
+
private recentValues;
|
|
222
|
+
private valueCounts;
|
|
200
223
|
constructor(catalog: T[], config: DualRecommenderConfig<T>);
|
|
201
224
|
setOwned(ids: Iterable<string>): void;
|
|
202
225
|
setSampleSize(k: number): void;
|
|
@@ -204,6 +227,11 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
204
227
|
engage(item: T, score: number): void;
|
|
205
228
|
recommend(count?: number): DualRecommendResult<T>;
|
|
206
229
|
private pickOne;
|
|
230
|
+
private pickUnderCapReplacement;
|
|
231
|
+
private atCap;
|
|
232
|
+
private isFamiliar;
|
|
233
|
+
private markSeen;
|
|
234
|
+
private recordPick;
|
|
207
235
|
private candidatePool;
|
|
208
236
|
seenCount(): number;
|
|
209
237
|
clearSeen(): void;
|
|
@@ -213,4 +241,4 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
213
241
|
declare function logDecadeBucket(value: number | null, multiplier?: number, prefix?: string): string | null;
|
|
214
242
|
declare function yearBucket(year: number | null, bandSize?: number): string | null;
|
|
215
243
|
|
|
216
|
-
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, 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
|
@@ -39,7 +39,7 @@ interface SlotTableState {
|
|
|
39
39
|
declare function emptyState(): SlotTableState;
|
|
40
40
|
declare function observe(state: SlotTableState, value: string, weight: number, config?: SlotTableConfig): void;
|
|
41
41
|
declare function decay(state: SlotTableState, factor: number): void;
|
|
42
|
-
declare function contribution(state: SlotTableState, value: string | null, config?: SlotTableConfig): number;
|
|
42
|
+
declare function contribution$1(state: SlotTableState, value: string | null, config?: SlotTableConfig): number;
|
|
43
43
|
declare function isEmpty$1(state: SlotTableState): boolean;
|
|
44
44
|
|
|
45
45
|
interface InterestConfig<T extends BaseItem> {
|
|
@@ -125,10 +125,12 @@ interface BucketConfig {
|
|
|
125
125
|
declare function emptyBucket(): BucketState;
|
|
126
126
|
declare function admit(state: BucketState, value: string, score: number, config?: BucketConfig): void;
|
|
127
127
|
declare function powerOf(state: BucketState, value: string | null): number;
|
|
128
|
+
declare function contribution(state: BucketState, value: string | null, epsilon: number): number;
|
|
128
129
|
declare function isEmpty(state: BucketState): boolean;
|
|
129
130
|
|
|
130
131
|
interface DualInterestConfig<T extends BaseItem> {
|
|
131
132
|
schema: Schema<T>;
|
|
133
|
+
weightedScoring?: boolean;
|
|
132
134
|
}
|
|
133
135
|
interface DualBucketState {
|
|
134
136
|
liked: Record<string, BucketState>;
|
|
@@ -156,16 +158,33 @@ declare function describe(state: DualBucketState, top?: number): {
|
|
|
156
158
|
disliked: Record<string, [string, number][]>;
|
|
157
159
|
};
|
|
158
160
|
|
|
161
|
+
interface DiversityCap {
|
|
162
|
+
feature: string;
|
|
163
|
+
maxShare: number;
|
|
164
|
+
window?: number;
|
|
165
|
+
familiarOnly?: boolean;
|
|
166
|
+
}
|
|
159
167
|
interface DualRecommenderConfig<T extends BaseItem> {
|
|
160
168
|
schema: Schema<T>;
|
|
161
169
|
sampleSize?: number;
|
|
162
170
|
shuffleThreshold?: number;
|
|
171
|
+
weightedScoring?: boolean;
|
|
172
|
+
seenWindow?: number;
|
|
173
|
+
diversityCap?: DiversityCap;
|
|
163
174
|
rng?: RNG;
|
|
164
175
|
}
|
|
176
|
+
interface ResolvedDiversityCap {
|
|
177
|
+
feature: string;
|
|
178
|
+
maxShare: number;
|
|
179
|
+
window: number;
|
|
180
|
+
familiarOnly: boolean;
|
|
181
|
+
}
|
|
165
182
|
interface ResolvedConfig<T extends BaseItem> {
|
|
166
183
|
interest: DualInterestConfig<T>;
|
|
167
184
|
sampleSize: number;
|
|
168
185
|
shuffleThreshold: number;
|
|
186
|
+
seenWindow: number;
|
|
187
|
+
diversityCap: ResolvedDiversityCap | null;
|
|
169
188
|
rng: RNG;
|
|
170
189
|
}
|
|
171
190
|
interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
@@ -176,6 +195,7 @@ interface DualRecommendDiagnostics<T extends BaseItem> {
|
|
|
176
195
|
bestLiked: number;
|
|
177
196
|
bestDisliked: number;
|
|
178
197
|
freshPagesIssued: number;
|
|
198
|
+
diversitySwapped: boolean;
|
|
179
199
|
breakdown: ItemScore['byFeature'] | null;
|
|
180
200
|
runnersUp: Array<{
|
|
181
201
|
item: T;
|
|
@@ -196,7 +216,10 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
196
216
|
readonly config: ResolvedConfig<T>;
|
|
197
217
|
private ownedIds;
|
|
198
218
|
private seen;
|
|
219
|
+
private seenOrder;
|
|
199
220
|
private freshPagesIssued;
|
|
221
|
+
private recentValues;
|
|
222
|
+
private valueCounts;
|
|
200
223
|
constructor(catalog: T[], config: DualRecommenderConfig<T>);
|
|
201
224
|
setOwned(ids: Iterable<string>): void;
|
|
202
225
|
setSampleSize(k: number): void;
|
|
@@ -204,6 +227,11 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
204
227
|
engage(item: T, score: number): void;
|
|
205
228
|
recommend(count?: number): DualRecommendResult<T>;
|
|
206
229
|
private pickOne;
|
|
230
|
+
private pickUnderCapReplacement;
|
|
231
|
+
private atCap;
|
|
232
|
+
private isFamiliar;
|
|
233
|
+
private markSeen;
|
|
234
|
+
private recordPick;
|
|
207
235
|
private candidatePool;
|
|
208
236
|
seenCount(): number;
|
|
209
237
|
clearSeen(): void;
|
|
@@ -213,4 +241,4 @@ declare class DualBucketRecommender<T extends BaseItem> {
|
|
|
213
241
|
declare function logDecadeBucket(value: number | null, multiplier?: number, prefix?: string): string | null;
|
|
214
242
|
declare function yearBucket(year: number | null, bandSize?: number): string | null;
|
|
215
243
|
|
|
216
|
-
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, 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
|
@@ -30,6 +30,7 @@ __export(reco_exports, {
|
|
|
30
30
|
SIGNALS: () => SIGNALS,
|
|
31
31
|
admit: () => admit,
|
|
32
32
|
applyEvent: () => applyEvent,
|
|
33
|
+
bucketContribution: () => contribution2,
|
|
33
34
|
contribution: () => contribution,
|
|
34
35
|
decay: () => decay,
|
|
35
36
|
defaultRng: () => defaultRng,
|
|
@@ -406,6 +407,14 @@ function powerOf(state, value) {
|
|
|
406
407
|
const slot = state.slots.find((s) => s.value === value);
|
|
407
408
|
return slot ? slot.power : 0;
|
|
408
409
|
}
|
|
410
|
+
function contribution2(state, value, epsilon) {
|
|
411
|
+
if (!value) return epsilon;
|
|
412
|
+
const slot = state.slots.find((s) => s.value === value);
|
|
413
|
+
if (!slot) return epsilon;
|
|
414
|
+
const total = state.slots.reduce((sum, s) => sum + s.power, 0);
|
|
415
|
+
if (total <= 0) return epsilon;
|
|
416
|
+
return epsilon + (1 - epsilon) * (slot.power / total);
|
|
417
|
+
}
|
|
409
418
|
function isEmpty2(state) {
|
|
410
419
|
return state.slots.length === 0;
|
|
411
420
|
}
|
|
@@ -445,14 +454,31 @@ function engageNegative(state, item, magnitude, config) {
|
|
|
445
454
|
if (!Number.isFinite(magnitude) || magnitude <= 0) return;
|
|
446
455
|
admitAcrossFeatures(state, "disliked", item, magnitude, config.schema);
|
|
447
456
|
}
|
|
457
|
+
function featureWeight2(fc, featureCount) {
|
|
458
|
+
if (typeof fc.weight === "number") return fc.weight;
|
|
459
|
+
return featureCount > 0 ? 1 / featureCount : 0;
|
|
460
|
+
}
|
|
461
|
+
function epsilonOf(fc) {
|
|
462
|
+
return fc.epsilon ?? DEFAULT_EPSILON;
|
|
463
|
+
}
|
|
448
464
|
function scoreItem2(state, item, config) {
|
|
449
465
|
let liked = 0;
|
|
450
466
|
let disliked = 0;
|
|
451
467
|
const byFeature = [];
|
|
452
|
-
|
|
468
|
+
const entries = Object.entries(config.schema);
|
|
469
|
+
for (const [key, fc] of entries) {
|
|
453
470
|
const value = fc.extract(item);
|
|
454
|
-
|
|
455
|
-
|
|
471
|
+
let l;
|
|
472
|
+
let d;
|
|
473
|
+
if (config.weightedScoring) {
|
|
474
|
+
const w = featureWeight2(fc, entries.length);
|
|
475
|
+
const eps = epsilonOf(fc);
|
|
476
|
+
l = w * contribution2(state.liked[key], value, eps);
|
|
477
|
+
d = w * contribution2(state.disliked[key], value, eps);
|
|
478
|
+
} else {
|
|
479
|
+
l = powerOf(state.liked[key], value);
|
|
480
|
+
d = powerOf(state.disliked[key], value);
|
|
481
|
+
}
|
|
456
482
|
liked += l;
|
|
457
483
|
disliked += d;
|
|
458
484
|
byFeature.push({ key, value, liked: l, disliked: d });
|
|
@@ -477,13 +503,21 @@ function describe2(state, top = 4) {
|
|
|
477
503
|
}
|
|
478
504
|
|
|
479
505
|
// src/reco/dual-bucket/recommender.ts
|
|
506
|
+
var DEFAULT_DIVERSITY_WINDOW = 20;
|
|
480
507
|
function resolve2(input) {
|
|
481
508
|
return {
|
|
482
|
-
interest: { schema: input.schema },
|
|
509
|
+
interest: { schema: input.schema, weightedScoring: input.weightedScoring ?? false },
|
|
483
510
|
// 100 is a heavy exploit setting; drop to 20–30 if exploration feels
|
|
484
511
|
// weak. Live-tunable via setSampleSize().
|
|
485
512
|
sampleSize: input.sampleSize ?? 100,
|
|
486
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,
|
|
487
521
|
rng: input.rng ?? defaultRng
|
|
488
522
|
};
|
|
489
523
|
}
|
|
@@ -491,7 +525,12 @@ var DualBucketRecommender = class {
|
|
|
491
525
|
constructor(catalog, config) {
|
|
492
526
|
this.ownedIds = /* @__PURE__ */ new Set();
|
|
493
527
|
this.seen = /* @__PURE__ */ new Set();
|
|
528
|
+
this.seenOrder = [];
|
|
494
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();
|
|
495
534
|
this.catalog = catalog;
|
|
496
535
|
this.byId = new Map(catalog.map((i) => [i.id, i]));
|
|
497
536
|
this.config = resolve2(config);
|
|
@@ -523,7 +562,8 @@ var DualBucketRecommender = class {
|
|
|
523
562
|
items.push(result.item);
|
|
524
563
|
scores.push(result.diagnostics.bestNet);
|
|
525
564
|
lastDiag = result.diagnostics;
|
|
526
|
-
this.
|
|
565
|
+
this.markSeen(result.item.id);
|
|
566
|
+
this.recordPick(result.item);
|
|
527
567
|
}
|
|
528
568
|
return {
|
|
529
569
|
items,
|
|
@@ -536,6 +576,7 @@ var DualBucketRecommender = class {
|
|
|
536
576
|
bestLiked: 0,
|
|
537
577
|
bestDisliked: 0,
|
|
538
578
|
freshPagesIssued: this.freshPagesIssued,
|
|
579
|
+
diversitySwapped: false,
|
|
539
580
|
breakdown: null,
|
|
540
581
|
runnersUp: []
|
|
541
582
|
}
|
|
@@ -545,6 +586,7 @@ var DualBucketRecommender = class {
|
|
|
545
586
|
let pool = this.candidatePool();
|
|
546
587
|
if (pool.length < this.config.shuffleThreshold) {
|
|
547
588
|
this.seen.clear();
|
|
589
|
+
this.seenOrder.length = 0;
|
|
548
590
|
this.freshPagesIssued += 1;
|
|
549
591
|
pool = this.candidatePool();
|
|
550
592
|
}
|
|
@@ -558,6 +600,19 @@ var DualBucketRecommender = class {
|
|
|
558
600
|
const zero = scored.find((x) => x.s.net === 0);
|
|
559
601
|
if (zero) winner = zero;
|
|
560
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
|
+
}
|
|
561
616
|
const runnersUp = scored.filter((x) => x.item.id !== winner.item.id).map((x) => ({
|
|
562
617
|
item: x.item,
|
|
563
618
|
net: x.s.net,
|
|
@@ -574,11 +629,71 @@ var DualBucketRecommender = class {
|
|
|
574
629
|
bestLiked: winner.s.liked,
|
|
575
630
|
bestDisliked: winner.s.disliked,
|
|
576
631
|
freshPagesIssued: this.freshPagesIssued,
|
|
632
|
+
diversitySwapped,
|
|
577
633
|
breakdown: winner.s.byFeature,
|
|
578
634
|
runnersUp
|
|
579
635
|
}
|
|
580
636
|
};
|
|
581
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
|
+
}
|
|
582
697
|
candidatePool() {
|
|
583
698
|
return this.catalog.filter((i) => !this.ownedIds.has(i.id) && !this.seen.has(i.id));
|
|
584
699
|
}
|
|
@@ -587,6 +702,9 @@ var DualBucketRecommender = class {
|
|
|
587
702
|
}
|
|
588
703
|
clearSeen() {
|
|
589
704
|
this.seen.clear();
|
|
705
|
+
this.seenOrder.length = 0;
|
|
706
|
+
this.recentValues.length = 0;
|
|
707
|
+
this.valueCounts.clear();
|
|
590
708
|
this.freshPagesIssued = 0;
|
|
591
709
|
}
|
|
592
710
|
reset() {
|
|
@@ -595,6 +713,9 @@ var DualBucketRecommender = class {
|
|
|
595
713
|
this.interest.disliked = fresh.disliked;
|
|
596
714
|
this.interest.totalEvents = 0;
|
|
597
715
|
this.seen.clear();
|
|
716
|
+
this.seenOrder.length = 0;
|
|
717
|
+
this.recentValues.length = 0;
|
|
718
|
+
this.valueCounts.clear();
|
|
598
719
|
this.freshPagesIssued = 0;
|
|
599
720
|
}
|
|
600
721
|
};
|
|
@@ -633,6 +754,7 @@ function yearBucket(year, bandSize = 5) {
|
|
|
633
754
|
SIGNALS,
|
|
634
755
|
admit,
|
|
635
756
|
applyEvent,
|
|
757
|
+
bucketContribution,
|
|
636
758
|
contribution,
|
|
637
759
|
decay,
|
|
638
760
|
defaultRng,
|
package/dist/reco.mjs
CHANGED
|
@@ -350,6 +350,14 @@ function powerOf(state, value) {
|
|
|
350
350
|
const slot = state.slots.find((s) => s.value === value);
|
|
351
351
|
return slot ? slot.power : 0;
|
|
352
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
|
+
}
|
|
353
361
|
function isEmpty2(state) {
|
|
354
362
|
return state.slots.length === 0;
|
|
355
363
|
}
|
|
@@ -389,14 +397,31 @@ function engageNegative(state, item, magnitude, config) {
|
|
|
389
397
|
if (!Number.isFinite(magnitude) || magnitude <= 0) return;
|
|
390
398
|
admitAcrossFeatures(state, "disliked", item, magnitude, config.schema);
|
|
391
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
|
+
}
|
|
392
407
|
function scoreItem2(state, item, config) {
|
|
393
408
|
let liked = 0;
|
|
394
409
|
let disliked = 0;
|
|
395
410
|
const byFeature = [];
|
|
396
|
-
|
|
411
|
+
const entries = Object.entries(config.schema);
|
|
412
|
+
for (const [key, fc] of entries) {
|
|
397
413
|
const value = fc.extract(item);
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
}
|
|
400
425
|
liked += l;
|
|
401
426
|
disliked += d;
|
|
402
427
|
byFeature.push({ key, value, liked: l, disliked: d });
|
|
@@ -421,13 +446,21 @@ function describe2(state, top = 4) {
|
|
|
421
446
|
}
|
|
422
447
|
|
|
423
448
|
// src/reco/dual-bucket/recommender.ts
|
|
449
|
+
var DEFAULT_DIVERSITY_WINDOW = 20;
|
|
424
450
|
function resolve2(input) {
|
|
425
451
|
return {
|
|
426
|
-
interest: { schema: input.schema },
|
|
452
|
+
interest: { schema: input.schema, weightedScoring: input.weightedScoring ?? false },
|
|
427
453
|
// 100 is a heavy exploit setting; drop to 20–30 if exploration feels
|
|
428
454
|
// weak. Live-tunable via setSampleSize().
|
|
429
455
|
sampleSize: input.sampleSize ?? 100,
|
|
430
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,
|
|
431
464
|
rng: input.rng ?? defaultRng
|
|
432
465
|
};
|
|
433
466
|
}
|
|
@@ -435,7 +468,12 @@ var DualBucketRecommender = class {
|
|
|
435
468
|
constructor(catalog, config) {
|
|
436
469
|
this.ownedIds = /* @__PURE__ */ new Set();
|
|
437
470
|
this.seen = /* @__PURE__ */ new Set();
|
|
471
|
+
this.seenOrder = [];
|
|
438
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();
|
|
439
477
|
this.catalog = catalog;
|
|
440
478
|
this.byId = new Map(catalog.map((i) => [i.id, i]));
|
|
441
479
|
this.config = resolve2(config);
|
|
@@ -467,7 +505,8 @@ var DualBucketRecommender = class {
|
|
|
467
505
|
items.push(result.item);
|
|
468
506
|
scores.push(result.diagnostics.bestNet);
|
|
469
507
|
lastDiag = result.diagnostics;
|
|
470
|
-
this.
|
|
508
|
+
this.markSeen(result.item.id);
|
|
509
|
+
this.recordPick(result.item);
|
|
471
510
|
}
|
|
472
511
|
return {
|
|
473
512
|
items,
|
|
@@ -480,6 +519,7 @@ var DualBucketRecommender = class {
|
|
|
480
519
|
bestLiked: 0,
|
|
481
520
|
bestDisliked: 0,
|
|
482
521
|
freshPagesIssued: this.freshPagesIssued,
|
|
522
|
+
diversitySwapped: false,
|
|
483
523
|
breakdown: null,
|
|
484
524
|
runnersUp: []
|
|
485
525
|
}
|
|
@@ -489,6 +529,7 @@ var DualBucketRecommender = class {
|
|
|
489
529
|
let pool = this.candidatePool();
|
|
490
530
|
if (pool.length < this.config.shuffleThreshold) {
|
|
491
531
|
this.seen.clear();
|
|
532
|
+
this.seenOrder.length = 0;
|
|
492
533
|
this.freshPagesIssued += 1;
|
|
493
534
|
pool = this.candidatePool();
|
|
494
535
|
}
|
|
@@ -502,6 +543,19 @@ var DualBucketRecommender = class {
|
|
|
502
543
|
const zero = scored.find((x) => x.s.net === 0);
|
|
503
544
|
if (zero) winner = zero;
|
|
504
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
|
+
}
|
|
505
559
|
const runnersUp = scored.filter((x) => x.item.id !== winner.item.id).map((x) => ({
|
|
506
560
|
item: x.item,
|
|
507
561
|
net: x.s.net,
|
|
@@ -518,11 +572,71 @@ var DualBucketRecommender = class {
|
|
|
518
572
|
bestLiked: winner.s.liked,
|
|
519
573
|
bestDisliked: winner.s.disliked,
|
|
520
574
|
freshPagesIssued: this.freshPagesIssued,
|
|
575
|
+
diversitySwapped,
|
|
521
576
|
breakdown: winner.s.byFeature,
|
|
522
577
|
runnersUp
|
|
523
578
|
}
|
|
524
579
|
};
|
|
525
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
|
+
}
|
|
526
640
|
candidatePool() {
|
|
527
641
|
return this.catalog.filter((i) => !this.ownedIds.has(i.id) && !this.seen.has(i.id));
|
|
528
642
|
}
|
|
@@ -531,6 +645,9 @@ var DualBucketRecommender = class {
|
|
|
531
645
|
}
|
|
532
646
|
clearSeen() {
|
|
533
647
|
this.seen.clear();
|
|
648
|
+
this.seenOrder.length = 0;
|
|
649
|
+
this.recentValues.length = 0;
|
|
650
|
+
this.valueCounts.clear();
|
|
534
651
|
this.freshPagesIssued = 0;
|
|
535
652
|
}
|
|
536
653
|
reset() {
|
|
@@ -539,6 +656,9 @@ var DualBucketRecommender = class {
|
|
|
539
656
|
this.interest.disliked = fresh.disliked;
|
|
540
657
|
this.interest.totalEvents = 0;
|
|
541
658
|
this.seen.clear();
|
|
659
|
+
this.seenOrder.length = 0;
|
|
660
|
+
this.recentValues.length = 0;
|
|
661
|
+
this.valueCounts.clear();
|
|
542
662
|
this.freshPagesIssued = 0;
|
|
543
663
|
}
|
|
544
664
|
};
|
|
@@ -576,6 +696,7 @@ export {
|
|
|
576
696
|
SIGNALS,
|
|
577
697
|
admit,
|
|
578
698
|
applyEvent,
|
|
699
|
+
contribution2 as bucketContribution,
|
|
579
700
|
contribution,
|
|
580
701
|
decay,
|
|
581
702
|
defaultRng,
|