@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 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.seen.add(result.item.id);
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.seen.add(result.item.id);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logg/signals",
3
- "version": "0.4.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",