@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 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 deterministic tests) are exported from
527
- the same entry for callers building custom pipelines or CLIs on top.
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
- for (const [key, fc] of Object.entries(config.schema)) {
468
+ const entries = Object.entries(config.schema);
469
+ for (const [key, fc] of entries) {
453
470
  const value = fc.extract(item);
454
- const l = powerOf(state.liked[key], value);
455
- const d = powerOf(state.disliked[key], value);
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.seen.add(result.item.id);
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
- for (const [key, fc] of Object.entries(config.schema)) {
411
+ const entries = Object.entries(config.schema);
412
+ for (const [key, fc] of entries) {
397
413
  const value = fc.extract(item);
398
- const l = powerOf(state.liked[key], value);
399
- const d = powerOf(state.disliked[key], value);
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.seen.add(result.item.id);
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logg/signals",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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",