@dloizides/taste-engine 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,18 +1,34 @@
1
- # Changelog
2
-
3
- ## 0.1.0
4
-
5
- Initial release.
6
-
7
- - `AXES` - the canonical 26-axis list (18 genre, 8 structure-and-feel) plus `AxisKey`,
8
- `AxisVector`, `zeroAxisVector`, `l2Norm`, `dot`, `cosine`.
9
- - `buildTasteVector` - log-scaled hours, recency doubling, 0.15 weight for owned-but-unplayed,
10
- quiz-verdict modulation, L2-normalised output.
11
- - `axisConfidence` - per-axis evidence saturating to 0..1.
12
- - `selectQuizCards` - picks cards that reduce uncertainty on the least-known axes and never
13
- returns an owned title.
14
- - `scoreCatalog` - cosine x platform gate x novelty x quality x mood, with an exact per-axis
15
- decomposition in `terms`.
16
- - `diversify` - greedy genre-cluster cap over the ranked list.
17
- - `explain` - top axes derived from the real scoring terms, nearest owned title, platform gap.
18
- - 34 tests, including a 2,100-title scoring run held under 100ms.
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ Closes the second half of spec 4.4, which 0.1.0 did not implement: nothing stopped the
6
+ results being twenty near-clones of the user's favourite game.
7
+
8
+ - `ScoreOptions.ownedTitles?: readonly OwnedTitle[]` (optional; the existing call shape
9
+ still compiles).
10
+ - `ScoredTitle.factors` reports `breadth`, `novelty`, `quality` and `mood` separately.
11
+ - The old axis-concentration discount is now named `breadth`, which is what it measured.
12
+ - New `novelty`: `1 - 0.65 * clamp(maxCosineToTop5Owned) ^ 2`. A clone scores 0.35, an
13
+ unrelated title 1.0. Without `ownedTitles` it is exactly 1.0 - never 0, never NaN.
14
+ - README corrected: `gapPlatform` marks a single-platform exclusive on the TITLE and does
15
+ not know the user's platforms.
16
+ - 42 tests. Negative control run on the new term: zeroing NOVELTY_MAX_DISCOUNT turns the
17
+ near-identical test red.
18
+
19
+ ## 0.1.0
20
+
21
+ Initial release.
22
+
23
+ - `AXES` - the canonical 26-axis list (18 genre, 8 structure-and-feel) plus `AxisKey`,
24
+ `AxisVector`, `zeroAxisVector`, `l2Norm`, `dot`, `cosine`.
25
+ - `buildTasteVector` - log-scaled hours, recency doubling, 0.15 weight for owned-but-unplayed,
26
+ quiz-verdict modulation, L2-normalised output.
27
+ - `axisConfidence` - per-axis evidence saturating to 0..1.
28
+ - `selectQuizCards` - picks cards that reduce uncertainty on the least-known axes and never
29
+ returns an owned title.
30
+ - `scoreCatalog` - cosine x platform gate x novelty x quality x mood, with an exact per-axis
31
+ decomposition in `terms`.
32
+ - `diversify` - greedy genre-cluster cap over the ranked list.
33
+ - `explain` - top axes derived from the real scoring terms, nearest owned title, platform gap.
34
+ - 34 tests, including a 2,100-title scoring run held under 100ms.
package/README.md CHANGED
@@ -1,87 +1,91 @@
1
- # @dloizides/taste-engine
2
-
3
- Weighted-vector taste modelling over a fixed 26-axis space. Pure TypeScript: no I/O, no
4
- React, no network, no clock. Everything runs in the browser against a catalogue the caller
5
- already has in memory.
6
-
7
- Built for NextGame (recommend console games from a Steam library), but nothing in it knows
8
- what a game is - it is 26 named axes, a library of weighted items, and a catalogue to rank.
9
-
10
- ## The axes
11
-
12
- 18 genre axes, then 8 structure-and-feel axes. Order is part of the contract: `diversify`
13
- clusters on the first 18.
14
-
15
- ```
16
- shooter action rpg jrpg strategy simulation racing sports platformer puzzle horror
17
- adventure roguelike fighting metroidvania soulslike mmo survival
18
- sessionLength difficulty narrative coop competitive relaxing replayability artForward
19
- ```
20
-
21
- ## API
22
-
23
- ```ts
24
- buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector
25
- axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number>
26
- selectQuizCards(catalog, conf, count, ownedIds?): string[]
27
- scoreCatalog(taste, catalog, opts: ScoreOptions): ScoredTitle[]
28
- diversify(scored, limit, maxPerCluster): ScoredTitle[]
29
- explain(taste, title, owned): Explanation
30
- ```
31
-
32
- ## How the numbers are chosen
33
-
34
- **`buildTasteVector`** - per-title weight is `log1p(hours) * recency * ownership`, where
35
- recency is `2.0` for a title played recently and `1.0` otherwise, and ownership is `1.0`
36
- above 0.5 hours and `0.15` below it. Logarithmic hours stop a single 900-hour habit from
37
- flattening the rest of the library: 900h carries `log1p(900) = 6.80` against 40h at `3.71`,
38
- not 22 times as much. For an unplayed title `log1p(0)` is 0 and would erase it entirely, so
39
- the log term is replaced by 1 and only the `0.15` ownership weight survives - an unbought
40
- opinion, not a played one. The result is L2-normalised, so downstream cosine is a pure
41
- direction comparison.
42
-
43
- A `QuizAnswer` modulates the weight of the owned title it names: `loved` x2, `liked` x1.25,
44
- `bounced` x0.25, `never` x0. An answer naming a title outside the library is ignored - it
45
- carries no axes to add.
46
-
47
- **`axisConfidence`** - `1 - exp(-evidence / 4)` per axis. Zero evidence gives exactly zero
48
- confidence, which is what `selectQuizCards` hunts for.
49
-
50
- **`selectQuizCards`** - ranks candidates by how much of their axis mass lands on
51
- low-confidence axes. `ownedIds` is a parameter rather than a pre-filter so a caller cannot
52
- forget it and quiz someone on a game they already own.
53
-
54
- **`scoreCatalog`** - `cosine * platformGate * novelty * quality * mood`.
55
- - `platformGate` is 0 or 1. An owned id and a title on no allowed platform both score
56
- exactly `0`, not merely a low number.
57
- - `quality` maps a critic score onto `0.6..1.0`; a `null` critic score is `0.8`. Absence of
58
- a score is neutral, never a penalty - most console back-catalogue has no score at all.
59
- - `novelty` discounts a title whose axis mass sits on one axis.
60
- - `mood` lifts by up to 50% for a title matching the requested mood axes.
61
-
62
- `terms` is the per-axis decomposition of the product, so the terms sum to the score exactly.
63
- That is what makes `explain` honest.
64
-
65
- **`explain`** - runs the title back through `scoreCatalog` and sorts the real terms. It does
66
- not recompute a lookalike heuristic, so an explanation cannot disagree with the ranking it
67
- explains. `gapPlatform` names the platform of a single-platform title: the one case where
68
- owning a platform is the only thing between the user and the game.
69
-
70
- ## Performance
71
-
72
- `score.perf.test.ts` scores 2,100 synthetic titles and fails above 100ms. The claim is a
73
- test, not a hope.
74
-
75
- ## NOT COVERED by the test suite
76
-
77
- The tests prove the arithmetic. They say nothing about whether the axis values assigned to
78
- any given title are *right*. A title tagged `difficulty: 0.9` that is actually easy passes
79
- every check here. Catalogue accuracy is a human spot-check, not a unit test.
80
-
81
- ## Install
82
-
83
- ```bash
84
- npm install @dloizides/taste-engine
85
- ```
86
-
87
- MIT.
1
+ # @dloizides/taste-engine
2
+
3
+ Weighted-vector taste modelling over a fixed 26-axis space. Pure TypeScript: no I/O, no
4
+ React, no network, no clock. Everything runs in the browser against a catalogue the caller
5
+ already has in memory.
6
+
7
+ Built for NextGame (recommend console games from a Steam library), but nothing in it knows
8
+ what a game is - it is 26 named axes, a library of weighted items, and a catalogue to rank.
9
+
10
+ ## The axes
11
+
12
+ 18 genre axes, then 8 structure-and-feel axes. Order is part of the contract: `diversify`
13
+ clusters on the first 18.
14
+
15
+ ```
16
+ shooter action rpg jrpg strategy simulation racing sports platformer puzzle horror
17
+ adventure roguelike fighting metroidvania soulslike mmo survival
18
+ sessionLength difficulty narrative coop competitive relaxing replayability artForward
19
+ ```
20
+
21
+ ## API
22
+
23
+ ```ts
24
+ buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector
25
+ axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number>
26
+ selectQuizCards(catalog, conf, count, ownedIds?): string[]
27
+ scoreCatalog(taste, catalog, opts: ScoreOptions): ScoredTitle[]
28
+ // ScoreOptions = { ownedIds; allowedPlatforms; mood?; ownedTitles? }
29
+ diversify(scored, limit, maxPerCluster): ScoredTitle[]
30
+ explain(taste, title, owned): Explanation
31
+ ```
32
+
33
+ ## How the numbers are chosen
34
+
35
+ **`buildTasteVector`** - per-title weight is `log1p(hours) * recency * ownership`, where
36
+ recency is `2.0` for a title played recently and `1.0` otherwise, and ownership is `1.0`
37
+ above 0.5 hours and `0.15` below it. Logarithmic hours stop a single 900-hour habit from
38
+ flattening the rest of the library: 900h carries `log1p(900) = 6.80` against 40h at `3.71`,
39
+ not 22 times as much. For an unplayed title `log1p(0)` is 0 and would erase it entirely, so
40
+ the log term is replaced by 1 and only the `0.15` ownership weight survives - an unbought
41
+ opinion, not a played one. The result is L2-normalised, so downstream cosine is a pure
42
+ direction comparison.
43
+
44
+ A `QuizAnswer` modulates the weight of the owned title it names: `loved` x2, `liked` x1.25,
45
+ `bounced` x0.25, `never` x0. An answer naming a title outside the library is ignored - it
46
+ carries no axes to add.
47
+
48
+ **`axisConfidence`** - `1 - exp(-evidence / 4)` per axis. Zero evidence gives exactly zero
49
+ confidence, which is what `selectQuizCards` hunts for.
50
+
51
+ **`selectQuizCards`** - ranks candidates by how much of their axis mass lands on
52
+ low-confidence axes. `ownedIds` is a parameter rather than a pre-filter so a caller cannot
53
+ forget it and quiz someone on a game they already own.
54
+
55
+ **`scoreCatalog`** - `cosine * platformGate * novelty * quality * mood`.
56
+ - `platformGate` is 0 or 1. An owned id and a title on no allowed platform both score
57
+ exactly `0`, not merely a low number.
58
+ - `quality` maps a critic score onto `0.6..1.0`; a `null` critic score is `0.8`. Absence of
59
+ a score is neutral, never a penalty - most console back-catalogue has no score at all.
60
+ - `novelty` discounts a title whose axis mass sits on one axis.
61
+ - `mood` lifts by up to 50% for a title matching the requested mood axes.
62
+
63
+ `terms` is the per-axis decomposition of the product, so the terms sum to the score exactly,
64
+ and `factors` reports `breadth`, `novelty`, `quality` and `mood` separately rather than
65
+ multiplied into one opaque number. That is what makes `explain` honest.
66
+
67
+ **`explain`** - runs the title back through `scoreCatalog` and sorts the real terms. It does
68
+ not recompute a lookalike heuristic, so an explanation cannot disagree with the ranking it
69
+ explains. `gapPlatform` is a property of the TITLE, not of the user: it is `platforms[0]` when a title
70
+ exists on exactly one platform, and `null` otherwise. `OwnedTitle` carries no platform, so the
71
+ engine cannot and does not know which platforms a user has. It marks a single-platform
72
+ exclusive; deciding whether that platform is a gap FOR THIS USER belongs to the caller.
73
+
74
+ ## Performance
75
+
76
+ `score.perf.test.ts` scores 2,100 synthetic titles and fails above 100ms. The claim is a
77
+ test, not a hope.
78
+
79
+ ## NOT COVERED by the test suite
80
+
81
+ The tests prove the arithmetic. They say nothing about whether the axis values assigned to
82
+ any given title are *right*. A title tagged `difficulty: 0.9` that is actually easy passes
83
+ every check here. Catalogue accuracy is a human spot-check, not a unit test.
84
+
85
+ ## Install
86
+
87
+ ```bash
88
+ npm install @dloizides/taste-engine
89
+ ```
90
+
91
+ MIT.
package/dist/index.d.mts CHANGED
@@ -33,8 +33,18 @@ interface QuizAnswer {
33
33
  titleId: string;
34
34
  verdict: 'loved' | 'liked' | 'bounced' | 'never';
35
35
  }
36
+ interface ScoreFactors {
37
+ /** Axis-concentration factor: discounts a title whose mass sits on one axis. */
38
+ breadth: number;
39
+ /** Similarity discount against the top-5 owned titles. Exactly 1 without `ownedTitles`. */
40
+ novelty: number;
41
+ quality: number;
42
+ mood: number;
43
+ }
36
44
  interface ScoreOptions {
37
45
  ownedIds: Set<string>;
46
+ /** Optional. Drives the novelty discount; absent means novelty is exactly 1. */
47
+ ownedTitles?: readonly OwnedTitle[];
38
48
  allowedPlatforms: string[];
39
49
  mood?: Partial<AxisVector>;
40
50
  }
@@ -43,6 +53,8 @@ interface ScoredTitle {
43
53
  score: number;
44
54
  /** Per-axis decomposition of `score`. The terms sum to `score` exactly. */
45
55
  terms: Record<AxisKey, number>;
56
+ /** The scalar multipliers behind `score`, kept separate instead of multiplied into one opaque number. */
57
+ factors: ScoreFactors;
46
58
  }
47
59
  interface Explanation {
48
60
  topAxes: AxisKey[];
@@ -81,7 +93,7 @@ declare function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number>;
81
93
  declare function selectQuizCards(catalog: CatalogTitle[], conf: Record<AxisKey, number>, count: number, ownedIds?: ReadonlySet<string>): string[];
82
94
 
83
95
  /**
84
- * score = cosine(taste, title) * platformGate * novelty * quality * mood.
96
+ * score = cosine(taste, title) * platformGate * breadth * novelty * quality * mood.
85
97
  * `terms` is the per-axis decomposition of that product, so the terms sum to the score.
86
98
  * Rows come back sorted by score, descending.
87
99
  */
@@ -100,9 +112,10 @@ declare function diversify(scored: ScoredTitle[], limit: number, maxPerCluster:
100
112
  * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can
101
113
  * never disagree with the ranking it explains.
102
114
  *
103
- * `gapPlatform` names the platform of a title that exists on exactly one - the case
104
- * where owning that platform is the thing standing between the user and the game.
115
+ * `gapPlatform` describes the TITLE, not the user: platforms[0] for a single-platform
116
+ * exclusive, null otherwise. OwnedTitle carries no platform, so this cannot know which
117
+ * platforms a user has - whether that exclusive is a gap for them is the caller's call.
105
118
  */
106
119
  declare function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation;
107
120
 
108
- export { AXES, type AxisKey, type AxisVector, type CatalogTitle, type Explanation, GENRE_AXES, GENRE_AXIS_COUNT, type OwnedTitle, type QuizAnswer, type ScoreOptions, type ScoredTitle, axisConfidence, buildTasteVector, clusterOf, cosine, diversify, dot, explain, l2Norm, scoreCatalog, selectQuizCards, titleWeight, zeroAxisVector };
121
+ export { AXES, type AxisKey, type AxisVector, type CatalogTitle, type Explanation, GENRE_AXES, GENRE_AXIS_COUNT, type OwnedTitle, type QuizAnswer, type ScoreFactors, type ScoreOptions, type ScoredTitle, axisConfidence, buildTasteVector, clusterOf, cosine, diversify, dot, explain, l2Norm, scoreCatalog, selectQuizCards, titleWeight, zeroAxisVector };
package/dist/index.d.ts CHANGED
@@ -33,8 +33,18 @@ interface QuizAnswer {
33
33
  titleId: string;
34
34
  verdict: 'loved' | 'liked' | 'bounced' | 'never';
35
35
  }
36
+ interface ScoreFactors {
37
+ /** Axis-concentration factor: discounts a title whose mass sits on one axis. */
38
+ breadth: number;
39
+ /** Similarity discount against the top-5 owned titles. Exactly 1 without `ownedTitles`. */
40
+ novelty: number;
41
+ quality: number;
42
+ mood: number;
43
+ }
36
44
  interface ScoreOptions {
37
45
  ownedIds: Set<string>;
46
+ /** Optional. Drives the novelty discount; absent means novelty is exactly 1. */
47
+ ownedTitles?: readonly OwnedTitle[];
38
48
  allowedPlatforms: string[];
39
49
  mood?: Partial<AxisVector>;
40
50
  }
@@ -43,6 +53,8 @@ interface ScoredTitle {
43
53
  score: number;
44
54
  /** Per-axis decomposition of `score`. The terms sum to `score` exactly. */
45
55
  terms: Record<AxisKey, number>;
56
+ /** The scalar multipliers behind `score`, kept separate instead of multiplied into one opaque number. */
57
+ factors: ScoreFactors;
46
58
  }
47
59
  interface Explanation {
48
60
  topAxes: AxisKey[];
@@ -81,7 +93,7 @@ declare function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number>;
81
93
  declare function selectQuizCards(catalog: CatalogTitle[], conf: Record<AxisKey, number>, count: number, ownedIds?: ReadonlySet<string>): string[];
82
94
 
83
95
  /**
84
- * score = cosine(taste, title) * platformGate * novelty * quality * mood.
96
+ * score = cosine(taste, title) * platformGate * breadth * novelty * quality * mood.
85
97
  * `terms` is the per-axis decomposition of that product, so the terms sum to the score.
86
98
  * Rows come back sorted by score, descending.
87
99
  */
@@ -100,9 +112,10 @@ declare function diversify(scored: ScoredTitle[], limit: number, maxPerCluster:
100
112
  * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can
101
113
  * never disagree with the ranking it explains.
102
114
  *
103
- * `gapPlatform` names the platform of a title that exists on exactly one - the case
104
- * where owning that platform is the thing standing between the user and the game.
115
+ * `gapPlatform` describes the TITLE, not the user: platforms[0] for a single-platform
116
+ * exclusive, null otherwise. OwnedTitle carries no platform, so this cannot know which
117
+ * platforms a user has - whether that exclusive is a gap for them is the caller's call.
105
118
  */
106
119
  declare function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation;
107
120
 
108
- export { AXES, type AxisKey, type AxisVector, type CatalogTitle, type Explanation, GENRE_AXES, GENRE_AXIS_COUNT, type OwnedTitle, type QuizAnswer, type ScoreOptions, type ScoredTitle, axisConfidence, buildTasteVector, clusterOf, cosine, diversify, dot, explain, l2Norm, scoreCatalog, selectQuizCards, titleWeight, zeroAxisVector };
121
+ export { AXES, type AxisKey, type AxisVector, type CatalogTitle, type Explanation, GENRE_AXES, GENRE_AXIS_COUNT, type OwnedTitle, type QuizAnswer, type ScoreFactors, type ScoreOptions, type ScoredTitle, axisConfidence, buildTasteVector, clusterOf, cosine, diversify, dot, explain, l2Norm, scoreCatalog, selectQuizCards, titleWeight, zeroAxisVector };
package/dist/index.js CHANGED
@@ -144,7 +144,10 @@ var NEUTRAL_QUALITY = 0.8;
144
144
  var QUALITY_FLOOR = 0.6;
145
145
  var QUALITY_RANGE = 0.4;
146
146
  var CRITIC_SCORE_MAX = 100;
147
- var NOVELTY_STRENGTH = 0.2;
147
+ var BREADTH_STRENGTH = 0.2;
148
+ var NOVELTY_TOP_OWNED = 5;
149
+ var NOVELTY_MAX_DISCOUNT = 0.65;
150
+ var NOVELTY_SIMILARITY_EXPONENT = 2;
148
151
  var MOOD_STRENGTH = 0.5;
149
152
  function moodWeights(mood) {
150
153
  if (mood === void 0) {
@@ -166,7 +169,21 @@ function qualityTerm(criticScore) {
166
169
  const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);
167
170
  return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);
168
171
  }
169
- function noveltyTerm(axes) {
172
+ function topOwnedByWeight(owned) {
173
+ if (owned === void 0 || owned.length === 0) {
174
+ return [];
175
+ }
176
+ return [...owned].sort((left, right) => titleWeight(right) - titleWeight(left)).slice(0, NOVELTY_TOP_OWNED);
177
+ }
178
+ function noveltyTerm(axes, topOwned) {
179
+ let peak = 0;
180
+ for (const owned of topOwned) {
181
+ peak = Math.max(peak, cosine(axes, owned.axes));
182
+ }
183
+ const clamped = Math.min(Math.max(peak, 0), 1);
184
+ return 1 - NOVELTY_MAX_DISCOUNT * Math.pow(clamped, NOVELTY_SIMILARITY_EXPONENT);
185
+ }
186
+ function breadthTerm(axes) {
170
187
  let sum = 0;
171
188
  let peak = 0;
172
189
  for (const axis of AXES) {
@@ -176,7 +193,7 @@ function noveltyTerm(axes) {
176
193
  if (sum === 0) {
177
194
  return 1;
178
195
  }
179
- return 1 - NOVELTY_STRENGTH * (peak / sum);
196
+ return 1 - BREADTH_STRENGTH * (peak / sum);
180
197
  }
181
198
  function moodTerm(axes, mood) {
182
199
  if (mood.length === 0) {
@@ -190,8 +207,9 @@ function moodTerm(axes, mood) {
190
207
  }
191
208
  return 1 + MOOD_STRENGTH * (weighted / total);
192
209
  }
210
+ var NO_FACTORS = { breadth: 0, novelty: 0, quality: 0, mood: 0 };
193
211
  function zeroRow(id) {
194
- return { id, score: 0, terms: zeroAxisVector() };
212
+ return { id, score: 0, terms: zeroAxisVector(), factors: { ...NO_FACTORS } };
195
213
  }
196
214
  function scoreOne(context, title) {
197
215
  if (context.ownedIds.has(title.id)) {
@@ -204,7 +222,13 @@ function scoreOne(context, title) {
204
222
  if (denominator === 0) {
205
223
  return zeroRow(title.id);
206
224
  }
207
- const gain = noveltyTerm(title.axes) * qualityTerm(title.criticScore) * moodTerm(title.axes, context.mood);
225
+ const factors = {
226
+ breadth: breadthTerm(title.axes),
227
+ novelty: noveltyTerm(title.axes, context.topOwned),
228
+ quality: qualityTerm(title.criticScore),
229
+ mood: moodTerm(title.axes, context.mood)
230
+ };
231
+ const gain = factors.breadth * factors.novelty * factors.quality * factors.mood;
208
232
  const terms = zeroAxisVector();
209
233
  let score = 0;
210
234
  for (const axis of AXES) {
@@ -212,7 +236,7 @@ function scoreOne(context, title) {
212
236
  terms[axis] = term;
213
237
  score += term;
214
238
  }
215
- return { id: title.id, score, terms };
239
+ return { id: title.id, score, terms, factors };
216
240
  }
217
241
  function scoreCatalog(taste, catalog, opts) {
218
242
  const context = {
@@ -220,7 +244,8 @@ function scoreCatalog(taste, catalog, opts) {
220
244
  tasteNorm: l2Norm(taste),
221
245
  ownedIds: opts.ownedIds,
222
246
  allowed: new Set(opts.allowedPlatforms),
223
- mood: moodWeights(opts.mood)
247
+ mood: moodWeights(opts.mood),
248
+ topOwned: topOwnedByWeight(opts.ownedTitles)
224
249
  };
225
250
  const rows = catalog.map((title) => scoreOne(context, title));
226
251
  rows.sort((left, right) => right.score - left.score);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/axes.ts","../src/buildTasteVector.ts","../src/confidence.ts","../src/selectQuizCards.ts","../src/score.ts","../src/diversify.ts","../src/explain.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The canonical axis list. 18 genre axes followed by 8 structure-and-feel axes.\n * The ORDER is part of the contract: everything downstream that talks about\n * \"genre clusters\" means the first GENRE_AXIS_COUNT entries of this array.\n */\nexport const AXES = [\n // genre (18)\n 'shooter', 'action', 'rpg', 'jrpg', 'strategy', 'simulation', 'racing', 'sports', 'platformer',\n 'puzzle', 'horror', 'adventure', 'roguelike', 'fighting', 'metroidvania', 'soulslike', 'mmo', 'survival',\n // structure and feel (8)\n 'sessionLength', 'difficulty', 'narrative', 'coop', 'competitive', 'relaxing', 'replayability', 'artForward',\n] as const;\n\nexport type AxisKey = typeof AXES[number];\n\n/** Every value is 0..1. */\nexport type AxisVector = Record<AxisKey, number>;\n\nexport const GENRE_AXIS_COUNT = 18;\n\n/** The genre half of AXES - what `diversify` clusters on. */\nexport const GENRE_AXES: readonly AxisKey[] = AXES.slice(0, GENRE_AXIS_COUNT);\n\nexport function zeroAxisVector(): AxisVector {\n const out = {} as AxisVector;\n for (const axis of AXES) {\n out[axis] = 0;\n }\n return out;\n}\n\nexport function l2Norm(vector: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += vector[axis] * vector[axis];\n }\n return Math.sqrt(sum);\n}\n\nexport function dot(left: AxisVector, right: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += left[axis] * right[axis];\n }\n return sum;\n}\n\n/** Cosine similarity, defined as 0 (not NaN) when either side has no magnitude. */\nexport function cosine(left: AxisVector, right: AxisVector): number {\n const denominator = l2Norm(left) * l2Norm(right);\n if (denominator === 0) {\n return 0;\n }\n return dot(left, right) / denominator;\n}\n","import { AXES, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisVector } from './axes';\nimport type { OwnedTitle, QuizAnswer } from './types';\n\nconst RECENCY_MULTIPLIER = 2.0;\nconst OWNERSHIP_PLAYED = 1.0;\nconst OWNERSHIP_UNPLAYED = 0.15;\n/** Below this, a title counts as owned-but-unplayed; log1p would score it 0 and erase it. */\nconst PLAYED_HOURS_THRESHOLD = 0.5;\nconst UNPLAYED_BASE = 1;\n\nconst VERDICT_MULTIPLIER: Record<QuizAnswer['verdict'], number> = {\n loved: 2,\n liked: 1.25,\n bounced: 0.25,\n never: 0,\n};\n\n/**\n * weight = log1p(hours) * recency * ownership.\n *\n * recency is 2.0 when the title was played recently, else 1.0.\n * ownership is 1.0 when hours > 0.5, else 0.15.\n * For an unplayed title log1p(hours) is 0, which would erase the title entirely,\n * so the log term is replaced by UNPLAYED_BASE and only the 0.15 ownership weight remains.\n */\nexport function titleWeight(title: OwnedTitle): number {\n const played = title.hours > PLAYED_HOURS_THRESHOLD;\n const base = played ? Math.log1p(title.hours) : UNPLAYED_BASE;\n const recency = title.playedRecently ? RECENCY_MULTIPLIER : 1;\n const ownership = played ? OWNERSHIP_PLAYED : OWNERSHIP_UNPLAYED;\n return base * recency * ownership;\n}\n\n/**\n * Quiz answers modulate the weight of the OWNED title they name. An answer whose\n * titleId is not in the library is ignored - the vector is built from what the user\n * actually has, and a verdict on something they do not own carries no axes to add.\n */\nexport function buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector {\n const verdicts = new Map<string, number>();\n for (const answer of answers ?? []) {\n verdicts.set(answer.titleId, VERDICT_MULTIPLIER[answer.verdict]);\n }\n\n const accumulator = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title) * (verdicts.get(title.id) ?? 1);\n if (weight === 0) {\n continue;\n }\n for (const axis of AXES) {\n accumulator[axis] += weight * title.axes[axis];\n }\n }\n\n const norm = l2Norm(accumulator);\n if (norm === 0) {\n return accumulator;\n }\n for (const axis of AXES) {\n accumulator[axis] /= norm;\n }\n return accumulator;\n}\n","import { AXES, zeroAxisVector } from './axes';\nimport type { AxisKey } from './axes';\nimport { titleWeight } from './buildTasteVector';\nimport type { OwnedTitle } from './types';\n\n/** Weighted evidence at which an axis reaches ~63% confidence. */\nconst CONFIDENCE_SCALE = 4;\n\n/**\n * How much the library actually tells us about each axis, 0..1.\n * An axis no owned title expresses has zero evidence and therefore zero confidence -\n * which is what `selectQuizCards` targets.\n */\nexport function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number> {\n const evidence = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title);\n for (const axis of AXES) {\n evidence[axis] += weight * title.axes[axis];\n }\n }\n\n const confidence = zeroAxisVector();\n for (const axis of AXES) {\n confidence[axis] = 1 - Math.exp(-evidence[axis] / CONFIDENCE_SCALE);\n }\n return confidence;\n}\n","import { AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { CatalogTitle } from './types';\n\nconst NO_OWNED_IDS: ReadonlySet<string> = new Set<string>();\n\n/**\n * Information gain: how much of this title's axis mass sits on the axes we are LEAST\n * confident about. A card that only expresses axes we already understand teaches nothing.\n */\nfunction informationGain(title: CatalogTitle, confidence: Record<AxisKey, number>): number {\n let gain = 0;\n for (const axis of AXES) {\n gain += (1 - confidence[axis]) * title.axes[axis];\n }\n return gain;\n}\n\n/**\n * `ownedIds` is optional and defaults to empty. It is a parameter rather than a\n * pre-filter on `catalog` so a caller cannot forget it and quiz the user on games\n * they already have.\n */\nexport function selectQuizCards(\n catalog: CatalogTitle[],\n conf: Record<AxisKey, number>,\n count: number,\n ownedIds: ReadonlySet<string> = NO_OWNED_IDS,\n): string[] {\n if (count <= 0) {\n return [];\n }\n return catalog\n .filter((title) => !ownedIds.has(title.id))\n .map((title) => ({ id: title.id, gain: informationGain(title, conf) }))\n .sort((left, right) => right.gain - left.gain)\n .slice(0, count)\n .map((candidate) => candidate.id);\n}\n","import { AXES, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisKey, AxisVector } from './axes';\nimport type { CatalogTitle, ScoreOptions, ScoredTitle } from './types';\n\n/** Applied when a title has no critic score at all - absence is neutral, not bad. */\nconst NEUTRAL_QUALITY = 0.8;\nconst QUALITY_FLOOR = 0.6;\nconst QUALITY_RANGE = 0.4;\nconst CRITIC_SCORE_MAX = 100;\n/** How far a one-note title is discounted against a broad one. */\nconst NOVELTY_STRENGTH = 0.2;\n/** Maximum lift a fully matched mood can apply. */\nconst MOOD_STRENGTH = 0.5;\n\ninterface MoodWeight { axis: AxisKey; weight: number }\n\ninterface ScoreContext {\n taste: AxisVector;\n tasteNorm: number;\n ownedIds: Set<string>;\n allowed: Set<string>;\n mood: MoodWeight[];\n}\n\nfunction moodWeights(mood: Partial<AxisVector> | undefined): MoodWeight[] {\n if (mood === undefined) {\n return [];\n }\n const weights: MoodWeight[] = [];\n for (const axis of AXES) {\n const weight = mood[axis];\n if (weight !== undefined && weight > 0) {\n weights.push({ axis, weight });\n }\n }\n return weights;\n}\n\nfunction qualityTerm(criticScore: number | null): number {\n if (criticScore === null) {\n return NEUTRAL_QUALITY;\n }\n const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);\n return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);\n}\n\n/** Discounts a title whose axis mass is concentrated on a single axis. */\nfunction noveltyTerm(axes: AxisVector): number {\n let sum = 0;\n let peak = 0;\n for (const axis of AXES) {\n sum += axes[axis];\n peak = Math.max(peak, axes[axis]);\n }\n if (sum === 0) {\n return 1;\n }\n return 1 - NOVELTY_STRENGTH * (peak / sum);\n}\n\nfunction moodTerm(axes: AxisVector, mood: MoodWeight[]): number {\n if (mood.length === 0) {\n return 1;\n }\n let weighted = 0;\n let total = 0;\n for (const entry of mood) {\n weighted += entry.weight * axes[entry.axis];\n total += entry.weight;\n }\n return 1 + MOOD_STRENGTH * (weighted / total);\n}\n\nfunction zeroRow(id: string): ScoredTitle {\n return { id, score: 0, terms: zeroAxisVector() };\n}\n\nfunction scoreOne(context: ScoreContext, title: CatalogTitle): ScoredTitle {\n if (context.ownedIds.has(title.id)) {\n return zeroRow(title.id);\n }\n if (!title.platforms.some((platform) => context.allowed.has(platform))) {\n return zeroRow(title.id);\n }\n const denominator = context.tasteNorm * l2Norm(title.axes);\n if (denominator === 0) {\n return zeroRow(title.id);\n }\n\n const gain = noveltyTerm(title.axes) * qualityTerm(title.criticScore) * moodTerm(title.axes, context.mood);\n const terms = zeroAxisVector();\n let score = 0;\n for (const axis of AXES) {\n const term = (context.taste[axis] * title.axes[axis] / denominator) * gain;\n terms[axis] = term;\n score += term;\n }\n return { id: title.id, score, terms };\n}\n\n/**\n * score = cosine(taste, title) * platformGate * novelty * quality * mood.\n * `terms` is the per-axis decomposition of that product, so the terms sum to the score.\n * Rows come back sorted by score, descending.\n */\nexport function scoreCatalog(taste: AxisVector, catalog: CatalogTitle[], opts: ScoreOptions): ScoredTitle[] {\n const context: ScoreContext = {\n taste,\n tasteNorm: l2Norm(taste),\n ownedIds: opts.ownedIds,\n allowed: new Set(opts.allowedPlatforms),\n mood: moodWeights(opts.mood),\n };\n const rows = catalog.map((title) => scoreOne(context, title));\n rows.sort((left, right) => right.score - left.score);\n return rows;\n}\n","import { AXES, GENRE_AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { ScoredTitle } from './types';\n\n/** The genre axis carrying the largest share of a row's score. */\nexport function clusterOf(terms: Record<AxisKey, number>): AxisKey {\n let best: AxisKey = AXES[0];\n let bestValue = -Infinity;\n for (const axis of GENRE_AXES) {\n if (terms[axis] > bestValue) {\n bestValue = terms[axis];\n best = axis;\n }\n }\n return best;\n}\n\n/**\n * Greedy re-rank over the incoming order: keep the highest-scoring rows but never let\n * one genre cluster take more than `maxPerCluster` slots. Relative order is preserved.\n */\nexport function diversify(scored: ScoredTitle[], limit: number, maxPerCluster: number): ScoredTitle[] {\n const perCluster = new Map<AxisKey, number>();\n const out: ScoredTitle[] = [];\n for (const row of scored) {\n if (out.length >= limit) {\n break;\n }\n const cluster = clusterOf(row.terms);\n const taken = perCluster.get(cluster) ?? 0;\n if (taken >= maxPerCluster) {\n continue;\n }\n perCluster.set(cluster, taken + 1);\n out.push(row);\n }\n return out;\n}\n","import { AXES, cosine, zeroAxisVector } from './axes';\nimport type { AxisKey, AxisVector } from './axes';\nimport { scoreCatalog } from './score';\nimport type { CatalogTitle, Explanation, OwnedTitle } from './types';\n\nconst TOP_AXIS_COUNT = 3;\n\nfunction nearestOwnedId(axes: AxisVector, owned: OwnedTitle[]): string | null {\n let best: string | null = null;\n let bestSimilarity = -Infinity;\n for (const title of owned) {\n const similarity = cosine(axes, title.axes);\n if (similarity > bestSimilarity) {\n bestSimilarity = similarity;\n best = title.id;\n }\n }\n return best;\n}\n\n/**\n * The axes come from the REAL scoring terms: `explain` runs the title back through\n * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can\n * never disagree with the ranking it explains.\n *\n * `gapPlatform` names the platform of a title that exists on exactly one - the case\n * where owning that platform is the thing standing between the user and the game.\n */\nexport function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation {\n const rows = scoreCatalog(taste, [title], {\n ownedIds: new Set<string>(),\n allowedPlatforms: title.platforms,\n });\n const terms: Record<AxisKey, number> = rows[0]?.terms ?? zeroAxisVector();\n const topAxes = AXES.filter((axis) => terms[axis] > 0)\n .sort((left, right) => terms[right] - terms[left])\n .slice(0, TOP_AXIS_COUNT);\n\n return {\n topAxes,\n nearestOwnedId: nearestOwnedId(title.axes, owned),\n gapPlatform: title.platforms.length === 1 ? title.platforms[0] ?? null : null,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/axes.ts","../src/buildTasteVector.ts","../src/confidence.ts","../src/selectQuizCards.ts","../src/score.ts","../src/diversify.ts","../src/explain.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * The canonical axis list. 18 genre axes followed by 8 structure-and-feel axes.\n * The ORDER is part of the contract: everything downstream that talks about\n * \"genre clusters\" means the first GENRE_AXIS_COUNT entries of this array.\n */\nexport const AXES = [\n // genre (18)\n 'shooter', 'action', 'rpg', 'jrpg', 'strategy', 'simulation', 'racing', 'sports', 'platformer',\n 'puzzle', 'horror', 'adventure', 'roguelike', 'fighting', 'metroidvania', 'soulslike', 'mmo', 'survival',\n // structure and feel (8)\n 'sessionLength', 'difficulty', 'narrative', 'coop', 'competitive', 'relaxing', 'replayability', 'artForward',\n] as const;\n\nexport type AxisKey = typeof AXES[number];\n\n/** Every value is 0..1. */\nexport type AxisVector = Record<AxisKey, number>;\n\nexport const GENRE_AXIS_COUNT = 18;\n\n/** The genre half of AXES - what `diversify` clusters on. */\nexport const GENRE_AXES: readonly AxisKey[] = AXES.slice(0, GENRE_AXIS_COUNT);\n\nexport function zeroAxisVector(): AxisVector {\n const out = {} as AxisVector;\n for (const axis of AXES) {\n out[axis] = 0;\n }\n return out;\n}\n\nexport function l2Norm(vector: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += vector[axis] * vector[axis];\n }\n return Math.sqrt(sum);\n}\n\nexport function dot(left: AxisVector, right: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += left[axis] * right[axis];\n }\n return sum;\n}\n\n/** Cosine similarity, defined as 0 (not NaN) when either side has no magnitude. */\nexport function cosine(left: AxisVector, right: AxisVector): number {\n const denominator = l2Norm(left) * l2Norm(right);\n if (denominator === 0) {\n return 0;\n }\n return dot(left, right) / denominator;\n}\n","import { AXES, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisVector } from './axes';\nimport type { OwnedTitle, QuizAnswer } from './types';\n\nconst RECENCY_MULTIPLIER = 2.0;\nconst OWNERSHIP_PLAYED = 1.0;\nconst OWNERSHIP_UNPLAYED = 0.15;\n/** Below this, a title counts as owned-but-unplayed; log1p would score it 0 and erase it. */\nconst PLAYED_HOURS_THRESHOLD = 0.5;\nconst UNPLAYED_BASE = 1;\n\nconst VERDICT_MULTIPLIER: Record<QuizAnswer['verdict'], number> = {\n loved: 2,\n liked: 1.25,\n bounced: 0.25,\n never: 0,\n};\n\n/**\n * weight = log1p(hours) * recency * ownership.\n *\n * recency is 2.0 when the title was played recently, else 1.0.\n * ownership is 1.0 when hours > 0.5, else 0.15.\n * For an unplayed title log1p(hours) is 0, which would erase the title entirely,\n * so the log term is replaced by UNPLAYED_BASE and only the 0.15 ownership weight remains.\n */\nexport function titleWeight(title: OwnedTitle): number {\n const played = title.hours > PLAYED_HOURS_THRESHOLD;\n const base = played ? Math.log1p(title.hours) : UNPLAYED_BASE;\n const recency = title.playedRecently ? RECENCY_MULTIPLIER : 1;\n const ownership = played ? OWNERSHIP_PLAYED : OWNERSHIP_UNPLAYED;\n return base * recency * ownership;\n}\n\n/**\n * Quiz answers modulate the weight of the OWNED title they name. An answer whose\n * titleId is not in the library is ignored - the vector is built from what the user\n * actually has, and a verdict on something they do not own carries no axes to add.\n */\nexport function buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector {\n const verdicts = new Map<string, number>();\n for (const answer of answers ?? []) {\n verdicts.set(answer.titleId, VERDICT_MULTIPLIER[answer.verdict]);\n }\n\n const accumulator = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title) * (verdicts.get(title.id) ?? 1);\n if (weight === 0) {\n continue;\n }\n for (const axis of AXES) {\n accumulator[axis] += weight * title.axes[axis];\n }\n }\n\n const norm = l2Norm(accumulator);\n if (norm === 0) {\n return accumulator;\n }\n for (const axis of AXES) {\n accumulator[axis] /= norm;\n }\n return accumulator;\n}\n","import { AXES, zeroAxisVector } from './axes';\nimport type { AxisKey } from './axes';\nimport { titleWeight } from './buildTasteVector';\nimport type { OwnedTitle } from './types';\n\n/** Weighted evidence at which an axis reaches ~63% confidence. */\nconst CONFIDENCE_SCALE = 4;\n\n/**\n * How much the library actually tells us about each axis, 0..1.\n * An axis no owned title expresses has zero evidence and therefore zero confidence -\n * which is what `selectQuizCards` targets.\n */\nexport function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number> {\n const evidence = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title);\n for (const axis of AXES) {\n evidence[axis] += weight * title.axes[axis];\n }\n }\n\n const confidence = zeroAxisVector();\n for (const axis of AXES) {\n confidence[axis] = 1 - Math.exp(-evidence[axis] / CONFIDENCE_SCALE);\n }\n return confidence;\n}\n","import { AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { CatalogTitle } from './types';\n\nconst NO_OWNED_IDS: ReadonlySet<string> = new Set<string>();\n\n/**\n * Information gain: how much of this title's axis mass sits on the axes we are LEAST\n * confident about. A card that only expresses axes we already understand teaches nothing.\n */\nfunction informationGain(title: CatalogTitle, confidence: Record<AxisKey, number>): number {\n let gain = 0;\n for (const axis of AXES) {\n gain += (1 - confidence[axis]) * title.axes[axis];\n }\n return gain;\n}\n\n/**\n * `ownedIds` is optional and defaults to empty. It is a parameter rather than a\n * pre-filter on `catalog` so a caller cannot forget it and quiz the user on games\n * they already have.\n */\nexport function selectQuizCards(\n catalog: CatalogTitle[],\n conf: Record<AxisKey, number>,\n count: number,\n ownedIds: ReadonlySet<string> = NO_OWNED_IDS,\n): string[] {\n if (count <= 0) {\n return [];\n }\n return catalog\n .filter((title) => !ownedIds.has(title.id))\n .map((title) => ({ id: title.id, gain: informationGain(title, conf) }))\n .sort((left, right) => right.gain - left.gain)\n .slice(0, count)\n .map((candidate) => candidate.id);\n}\n","import { AXES, cosine, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisKey, AxisVector } from './axes';\nimport { titleWeight } from './buildTasteVector';\nimport type { CatalogTitle, OwnedTitle, ScoreFactors, ScoreOptions, ScoredTitle } from './types';\n\n/** Applied when a title has no critic score at all - absence is neutral, not bad. */\nconst NEUTRAL_QUALITY = 0.8;\nconst QUALITY_FLOOR = 0.6;\nconst QUALITY_RANGE = 0.4;\nconst CRITIC_SCORE_MAX = 100;\n/** How far a one-note title is discounted against a broad one. */\nconst BREADTH_STRENGTH = 0.2;\n/** How many owned titles the novelty discount looks at, ranked by titleWeight. */\nconst NOVELTY_TOP_OWNED = 5;\n/** A title identical to a top-5 favourite keeps 1 - 0.65 = 0.35 of its score. */\nconst NOVELTY_MAX_DISCOUNT = 0.65;\n/** Squared, so a loose resemblance is barely touched and only a near-clone is hit hard. */\nconst NOVELTY_SIMILARITY_EXPONENT = 2;\n/** Maximum lift a fully matched mood can apply. */\nconst MOOD_STRENGTH = 0.5;\n\ninterface MoodWeight { axis: AxisKey; weight: number }\n\ninterface ScoreContext {\n taste: AxisVector;\n tasteNorm: number;\n ownedIds: Set<string>;\n allowed: Set<string>;\n mood: MoodWeight[];\n topOwned: readonly OwnedTitle[];\n}\n\nfunction moodWeights(mood: Partial<AxisVector> | undefined): MoodWeight[] {\n if (mood === undefined) {\n return [];\n }\n const weights: MoodWeight[] = [];\n for (const axis of AXES) {\n const weight = mood[axis];\n if (weight !== undefined && weight > 0) {\n weights.push({ axis, weight });\n }\n }\n return weights;\n}\n\nfunction qualityTerm(criticScore: number | null): number {\n if (criticScore === null) {\n return NEUTRAL_QUALITY;\n }\n const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);\n return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);\n}\n\n/** Discounts a title whose axis mass is concentrated on a single axis. */\nfunction topOwnedByWeight(owned: readonly OwnedTitle[] | undefined): readonly OwnedTitle[] {\n if (owned === undefined || owned.length === 0) {\n return [];\n }\n return [...owned].sort((left, right) => titleWeight(right) - titleWeight(left)).slice(0, NOVELTY_TOP_OWNED);\n}\n\n/**\n * Spec 4.4: reduced if near-identical to a top-5 owned title.\n * novelty = 1 - 0.65 * max(cosine to a top-5 owned title, clamped 0..1) ^ 2.\n * No `ownedTitles` means no evidence of a clone, so the factor is exactly 1 - never 0, never NaN.\n */\nfunction noveltyTerm(axes: AxisVector, topOwned: readonly OwnedTitle[]): number {\n let peak = 0;\n for (const owned of topOwned) {\n peak = Math.max(peak, cosine(axes, owned.axes));\n }\n const clamped = Math.min(Math.max(peak, 0), 1);\n return 1 - NOVELTY_MAX_DISCOUNT * Math.pow(clamped, NOVELTY_SIMILARITY_EXPONENT);\n}\n\nfunction breadthTerm(axes: AxisVector): number {\n let sum = 0;\n let peak = 0;\n for (const axis of AXES) {\n sum += axes[axis];\n peak = Math.max(peak, axes[axis]);\n }\n if (sum === 0) {\n return 1;\n }\n return 1 - BREADTH_STRENGTH * (peak / sum);\n}\n\nfunction moodTerm(axes: AxisVector, mood: MoodWeight[]): number {\n if (mood.length === 0) {\n return 1;\n }\n let weighted = 0;\n let total = 0;\n for (const entry of mood) {\n weighted += entry.weight * axes[entry.axis];\n total += entry.weight;\n }\n return 1 + MOOD_STRENGTH * (weighted / total);\n}\n\nconst NO_FACTORS: ScoreFactors = { breadth: 0, novelty: 0, quality: 0, mood: 0 };\n\nfunction zeroRow(id: string): ScoredTitle {\n return { id, score: 0, terms: zeroAxisVector(), factors: { ...NO_FACTORS } };\n}\n\nfunction scoreOne(context: ScoreContext, title: CatalogTitle): ScoredTitle {\n if (context.ownedIds.has(title.id)) {\n return zeroRow(title.id);\n }\n if (!title.platforms.some((platform) => context.allowed.has(platform))) {\n return zeroRow(title.id);\n }\n const denominator = context.tasteNorm * l2Norm(title.axes);\n if (denominator === 0) {\n return zeroRow(title.id);\n }\n\n const factors: ScoreFactors = {\n breadth: breadthTerm(title.axes),\n novelty: noveltyTerm(title.axes, context.topOwned),\n quality: qualityTerm(title.criticScore),\n mood: moodTerm(title.axes, context.mood),\n };\n const gain = factors.breadth * factors.novelty * factors.quality * factors.mood;\n const terms = zeroAxisVector();\n let score = 0;\n for (const axis of AXES) {\n const term = (context.taste[axis] * title.axes[axis] / denominator) * gain;\n terms[axis] = term;\n score += term;\n }\n return { id: title.id, score, terms, factors };\n}\n\n/**\n * score = cosine(taste, title) * platformGate * breadth * novelty * quality * mood.\n * `terms` is the per-axis decomposition of that product, so the terms sum to the score.\n * Rows come back sorted by score, descending.\n */\nexport function scoreCatalog(taste: AxisVector, catalog: CatalogTitle[], opts: ScoreOptions): ScoredTitle[] {\n const context: ScoreContext = {\n taste,\n tasteNorm: l2Norm(taste),\n ownedIds: opts.ownedIds,\n allowed: new Set(opts.allowedPlatforms),\n mood: moodWeights(opts.mood),\n topOwned: topOwnedByWeight(opts.ownedTitles),\n };\n const rows = catalog.map((title) => scoreOne(context, title));\n rows.sort((left, right) => right.score - left.score);\n return rows;\n}\n","import { AXES, GENRE_AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { ScoredTitle } from './types';\n\n/** The genre axis carrying the largest share of a row's score. */\nexport function clusterOf(terms: Record<AxisKey, number>): AxisKey {\n let best: AxisKey = AXES[0];\n let bestValue = -Infinity;\n for (const axis of GENRE_AXES) {\n if (terms[axis] > bestValue) {\n bestValue = terms[axis];\n best = axis;\n }\n }\n return best;\n}\n\n/**\n * Greedy re-rank over the incoming order: keep the highest-scoring rows but never let\n * one genre cluster take more than `maxPerCluster` slots. Relative order is preserved.\n */\nexport function diversify(scored: ScoredTitle[], limit: number, maxPerCluster: number): ScoredTitle[] {\n const perCluster = new Map<AxisKey, number>();\n const out: ScoredTitle[] = [];\n for (const row of scored) {\n if (out.length >= limit) {\n break;\n }\n const cluster = clusterOf(row.terms);\n const taken = perCluster.get(cluster) ?? 0;\n if (taken >= maxPerCluster) {\n continue;\n }\n perCluster.set(cluster, taken + 1);\n out.push(row);\n }\n return out;\n}\n","import { AXES, cosine, zeroAxisVector } from './axes';\r\nimport type { AxisKey, AxisVector } from './axes';\r\nimport { scoreCatalog } from './score';\r\nimport type { CatalogTitle, Explanation, OwnedTitle } from './types';\r\n\r\nconst TOP_AXIS_COUNT = 3;\r\n\r\nfunction nearestOwnedId(axes: AxisVector, owned: OwnedTitle[]): string | null {\r\n let best: string | null = null;\r\n let bestSimilarity = -Infinity;\r\n for (const title of owned) {\r\n const similarity = cosine(axes, title.axes);\r\n if (similarity > bestSimilarity) {\r\n bestSimilarity = similarity;\r\n best = title.id;\r\n }\r\n }\r\n return best;\r\n}\r\n\r\n/**\r\n * The axes come from the REAL scoring terms: `explain` runs the title back through\r\n * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can\r\n * never disagree with the ranking it explains.\r\n *\r\n * `gapPlatform` describes the TITLE, not the user: platforms[0] for a single-platform\r\n * exclusive, null otherwise. OwnedTitle carries no platform, so this cannot know which\r\n * platforms a user has - whether that exclusive is a gap for them is the caller's call.\r\n */\r\nexport function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation {\r\n const rows = scoreCatalog(taste, [title], {\r\n ownedIds: new Set<string>(),\r\n allowedPlatforms: title.platforms,\r\n });\r\n const terms: Record<AxisKey, number> = rows[0]?.terms ?? zeroAxisVector();\r\n const topAxes = AXES.filter((axis) => terms[axis] > 0)\r\n .sort((left, right) => terms[right] - terms[left])\r\n .slice(0, TOP_AXIS_COUNT);\r\n\r\n return {\r\n topAxes,\r\n nearestOwnedId: nearestOwnedId(title.axes, owned),\r\n gapPlatform: title.platforms.length === 1 ? title.platforms[0] ?? null : null,\r\n };\r\n}\r\n"]}
package/dist/index.mjs CHANGED
@@ -142,7 +142,10 @@ var NEUTRAL_QUALITY = 0.8;
142
142
  var QUALITY_FLOOR = 0.6;
143
143
  var QUALITY_RANGE = 0.4;
144
144
  var CRITIC_SCORE_MAX = 100;
145
- var NOVELTY_STRENGTH = 0.2;
145
+ var BREADTH_STRENGTH = 0.2;
146
+ var NOVELTY_TOP_OWNED = 5;
147
+ var NOVELTY_MAX_DISCOUNT = 0.65;
148
+ var NOVELTY_SIMILARITY_EXPONENT = 2;
146
149
  var MOOD_STRENGTH = 0.5;
147
150
  function moodWeights(mood) {
148
151
  if (mood === void 0) {
@@ -164,7 +167,21 @@ function qualityTerm(criticScore) {
164
167
  const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);
165
168
  return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);
166
169
  }
167
- function noveltyTerm(axes) {
170
+ function topOwnedByWeight(owned) {
171
+ if (owned === void 0 || owned.length === 0) {
172
+ return [];
173
+ }
174
+ return [...owned].sort((left, right) => titleWeight(right) - titleWeight(left)).slice(0, NOVELTY_TOP_OWNED);
175
+ }
176
+ function noveltyTerm(axes, topOwned) {
177
+ let peak = 0;
178
+ for (const owned of topOwned) {
179
+ peak = Math.max(peak, cosine(axes, owned.axes));
180
+ }
181
+ const clamped = Math.min(Math.max(peak, 0), 1);
182
+ return 1 - NOVELTY_MAX_DISCOUNT * Math.pow(clamped, NOVELTY_SIMILARITY_EXPONENT);
183
+ }
184
+ function breadthTerm(axes) {
168
185
  let sum = 0;
169
186
  let peak = 0;
170
187
  for (const axis of AXES) {
@@ -174,7 +191,7 @@ function noveltyTerm(axes) {
174
191
  if (sum === 0) {
175
192
  return 1;
176
193
  }
177
- return 1 - NOVELTY_STRENGTH * (peak / sum);
194
+ return 1 - BREADTH_STRENGTH * (peak / sum);
178
195
  }
179
196
  function moodTerm(axes, mood) {
180
197
  if (mood.length === 0) {
@@ -188,8 +205,9 @@ function moodTerm(axes, mood) {
188
205
  }
189
206
  return 1 + MOOD_STRENGTH * (weighted / total);
190
207
  }
208
+ var NO_FACTORS = { breadth: 0, novelty: 0, quality: 0, mood: 0 };
191
209
  function zeroRow(id) {
192
- return { id, score: 0, terms: zeroAxisVector() };
210
+ return { id, score: 0, terms: zeroAxisVector(), factors: { ...NO_FACTORS } };
193
211
  }
194
212
  function scoreOne(context, title) {
195
213
  if (context.ownedIds.has(title.id)) {
@@ -202,7 +220,13 @@ function scoreOne(context, title) {
202
220
  if (denominator === 0) {
203
221
  return zeroRow(title.id);
204
222
  }
205
- const gain = noveltyTerm(title.axes) * qualityTerm(title.criticScore) * moodTerm(title.axes, context.mood);
223
+ const factors = {
224
+ breadth: breadthTerm(title.axes),
225
+ novelty: noveltyTerm(title.axes, context.topOwned),
226
+ quality: qualityTerm(title.criticScore),
227
+ mood: moodTerm(title.axes, context.mood)
228
+ };
229
+ const gain = factors.breadth * factors.novelty * factors.quality * factors.mood;
206
230
  const terms = zeroAxisVector();
207
231
  let score = 0;
208
232
  for (const axis of AXES) {
@@ -210,7 +234,7 @@ function scoreOne(context, title) {
210
234
  terms[axis] = term;
211
235
  score += term;
212
236
  }
213
- return { id: title.id, score, terms };
237
+ return { id: title.id, score, terms, factors };
214
238
  }
215
239
  function scoreCatalog(taste, catalog, opts) {
216
240
  const context = {
@@ -218,7 +242,8 @@ function scoreCatalog(taste, catalog, opts) {
218
242
  tasteNorm: l2Norm(taste),
219
243
  ownedIds: opts.ownedIds,
220
244
  allowed: new Set(opts.allowedPlatforms),
221
- mood: moodWeights(opts.mood)
245
+ mood: moodWeights(opts.mood),
246
+ topOwned: topOwnedByWeight(opts.ownedTitles)
222
247
  };
223
248
  const rows = catalog.map((title) => scoreOne(context, title));
224
249
  rows.sort((left, right) => right.score - left.score);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/axes.ts","../src/buildTasteVector.ts","../src/confidence.ts","../src/selectQuizCards.ts","../src/score.ts","../src/diversify.ts","../src/explain.ts"],"names":[],"mappings":";AAKO,IAAM,IAAA,GAAO;AAAA;AAAA,EAElB,SAAA;AAAA,EAAW,QAAA;AAAA,EAAU,KAAA;AAAA,EAAO,MAAA;AAAA,EAAQ,UAAA;AAAA,EAAY,YAAA;AAAA,EAAc,QAAA;AAAA,EAAU,QAAA;AAAA,EAAU,YAAA;AAAA,EAClF,QAAA;AAAA,EAAU,QAAA;AAAA,EAAU,WAAA;AAAA,EAAa,WAAA;AAAA,EAAa,UAAA;AAAA,EAAY,cAAA;AAAA,EAAgB,WAAA;AAAA,EAAa,KAAA;AAAA,EAAO,UAAA;AAAA;AAAA,EAE9F,eAAA;AAAA,EAAiB,YAAA;AAAA,EAAc,WAAA;AAAA,EAAa,MAAA;AAAA,EAAQ,aAAA;AAAA,EAAe,UAAA;AAAA,EAAY,eAAA;AAAA,EAAiB;AAClG;AAOO,IAAM,gBAAA,GAAmB;AAGzB,IAAM,UAAA,GAAiC,IAAA,CAAK,KAAA,CAAM,CAAA,EAAG,gBAAgB;AAErE,SAAS,cAAA,GAA6B;AAC3C,EAAA,MAAM,MAAM,EAAC;AACb,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,GAAA,CAAI,IAAI,CAAA,GAAI,CAAA;AAAA,EACd;AACA,EAAA,OAAO,GAAA;AACT;AAEO,SAAS,OAAO,MAAA,EAA4B;AACjD,EAAA,IAAI,GAAA,GAAM,CAAA;AACV,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,GAAA,IAAO,MAAA,CAAO,IAAI,CAAA,GAAI,MAAA,CAAO,IAAI,CAAA;AAAA,EACnC;AACA,EAAA,OAAO,IAAA,CAAK,KAAK,GAAG,CAAA;AACtB;AAEO,SAAS,GAAA,CAAI,MAAkB,KAAA,EAA2B;AAC/D,EAAA,IAAI,GAAA,GAAM,CAAA;AACV,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,GAAA,IAAO,IAAA,CAAK,IAAI,CAAA,GAAI,KAAA,CAAM,IAAI,CAAA;AAAA,EAChC;AACA,EAAA,OAAO,GAAA;AACT;AAGO,SAAS,MAAA,CAAO,MAAkB,KAAA,EAA2B;AAClE,EAAA,MAAM,WAAA,GAAc,MAAA,CAAO,IAAI,CAAA,GAAI,OAAO,KAAK,CAAA;AAC/C,EAAA,IAAI,gBAAgB,CAAA,EAAG;AACrB,IAAA,OAAO,CAAA;AAAA,EACT;AACA,EAAA,OAAO,GAAA,CAAI,IAAA,EAAM,KAAK,CAAA,GAAI,WAAA;AAC5B;;;AClDA,IAAM,kBAAA,GAAqB,CAAA;AAC3B,IAAM,gBAAA,GAAmB,CAAA;AACzB,IAAM,kBAAA,GAAqB,IAAA;AAE3B,IAAM,sBAAA,GAAyB,GAAA;AAC/B,IAAM,aAAA,GAAgB,CAAA;AAEtB,IAAM,kBAAA,GAA4D;AAAA,EAChE,KAAA,EAAO,CAAA;AAAA,EACP,KAAA,EAAO,IAAA;AAAA,EACP,OAAA,EAAS,IAAA;AAAA,EACT,KAAA,EAAO;AACT,CAAA;AAUO,SAAS,YAAY,KAAA,EAA2B;AACrD,EAAA,MAAM,MAAA,GAAS,MAAM,KAAA,GAAQ,sBAAA;AAC7B,EAAA,MAAM,OAAO,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,KAAA,CAAM,KAAK,CAAA,GAAI,aAAA;AAChD,EAAA,MAAM,OAAA,GAAU,KAAA,CAAM,cAAA,GAAiB,kBAAA,GAAqB,CAAA;AAC5D,EAAA,MAAM,SAAA,GAAY,SAAS,gBAAA,GAAmB,kBAAA;AAC9C,EAAA,OAAO,OAAO,OAAA,GAAU,SAAA;AAC1B;AAOO,SAAS,gBAAA,CAAiB,OAAqB,OAAA,EAAoC;AACxF,EAAA,MAAM,QAAA,uBAAe,GAAA,EAAoB;AACzC,EAAA,KAAA,MAAW,MAAA,IAAU,OAAA,IAAW,EAAC,EAAG;AAClC,IAAA,QAAA,CAAS,IAAI,MAAA,CAAO,OAAA,EAAS,kBAAA,CAAmB,MAAA,CAAO,OAAO,CAAC,CAAA;AAAA,EACjE;AAEA,EAAA,MAAM,cAAc,cAAA,EAAe;AACnC,EAAA,KAAA,MAAW,SAAS,KAAA,EAAO;AACzB,IAAA,MAAM,MAAA,GAAS,YAAY,KAAK,CAAA,IAAK,SAAS,GAAA,CAAI,KAAA,CAAM,EAAE,CAAA,IAAK,CAAA,CAAA;AAC/D,IAAA,IAAI,WAAW,CAAA,EAAG;AAChB,MAAA;AAAA,IACF;AACA,IAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,MAAA,WAAA,CAAY,IAAI,CAAA,IAAK,MAAA,GAAS,KAAA,CAAM,KAAK,IAAI,CAAA;AAAA,IAC/C;AAAA,EACF;AAEA,EAAA,MAAM,IAAA,GAAO,OAAO,WAAW,CAAA;AAC/B,EAAA,IAAI,SAAS,CAAA,EAAG;AACd,IAAA,OAAO,WAAA;AAAA,EACT;AACA,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,WAAA,CAAY,IAAI,CAAA,IAAK,IAAA;AAAA,EACvB;AACA,EAAA,OAAO,WAAA;AACT;;;AC1DA,IAAM,gBAAA,GAAmB,CAAA;AAOlB,SAAS,eAAe,KAAA,EAA8C;AAC3E,EAAA,MAAM,WAAW,cAAA,EAAe;AAChC,EAAA,KAAA,MAAW,SAAS,KAAA,EAAO;AACzB,IAAA,MAAM,MAAA,GAAS,YAAY,KAAK,CAAA;AAChC,IAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,MAAA,QAAA,CAAS,IAAI,CAAA,IAAK,MAAA,GAAS,KAAA,CAAM,KAAK,IAAI,CAAA;AAAA,IAC5C;AAAA,EACF;AAEA,EAAA,MAAM,aAAa,cAAA,EAAe;AAClC,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,UAAA,CAAW,IAAI,IAAI,CAAA,GAAI,IAAA,CAAK,IAAI,CAAC,QAAA,CAAS,IAAI,CAAA,GAAI,gBAAgB,CAAA;AAAA,EACpE;AACA,EAAA,OAAO,UAAA;AACT;;;ACvBA,IAAM,YAAA,uBAAwC,GAAA,EAAY;AAM1D,SAAS,eAAA,CAAgB,OAAqB,UAAA,EAA6C;AACzF,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,IAAA,IAAA,CAAS,IAAI,UAAA,CAAW,IAAI,CAAA,IAAK,KAAA,CAAM,KAAK,IAAI,CAAA;AAAA,EAClD;AACA,EAAA,OAAO,IAAA;AACT;AAOO,SAAS,eAAA,CACd,OAAA,EACA,IAAA,EACA,KAAA,EACA,WAAgC,YAAA,EACtB;AACV,EAAA,IAAI,SAAS,CAAA,EAAG;AACd,IAAA,OAAO,EAAC;AAAA,EACV;AACA,EAAA,OAAO,QACJ,MAAA,CAAO,CAAC,UAAU,CAAC,QAAA,CAAS,IAAI,KAAA,CAAM,EAAE,CAAC,CAAA,CACzC,GAAA,CAAI,CAAC,KAAA,MAAW,EAAE,IAAI,KAAA,CAAM,EAAA,EAAI,MAAM,eAAA,CAAgB,KAAA,EAAO,IAAI,CAAA,GAAI,CAAA,CACrE,IAAA,CAAK,CAAC,IAAA,EAAM,KAAA,KAAU,MAAM,IAAA,GAAO,IAAA,CAAK,IAAI,CAAA,CAC5C,KAAA,CAAM,GAAG,KAAK,CAAA,CACd,IAAI,CAAC,SAAA,KAAc,UAAU,EAAE,CAAA;AACpC;;;ACjCA,IAAM,eAAA,GAAkB,GAAA;AACxB,IAAM,aAAA,GAAgB,GAAA;AACtB,IAAM,aAAA,GAAgB,GAAA;AACtB,IAAM,gBAAA,GAAmB,GAAA;AAEzB,IAAM,gBAAA,GAAmB,GAAA;AAEzB,IAAM,aAAA,GAAgB,GAAA;AAYtB,SAAS,YAAY,IAAA,EAAqD;AACxE,EAAA,IAAI,SAAS,MAAA,EAAW;AACtB,IAAA,OAAO,EAAC;AAAA,EACV;AACA,EAAA,MAAM,UAAwB,EAAC;AAC/B,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,MAAM,MAAA,GAAS,KAAK,IAAI,CAAA;AACxB,IAAA,IAAI,MAAA,KAAW,MAAA,IAAa,MAAA,GAAS,CAAA,EAAG;AACtC,MAAA,OAAA,CAAQ,IAAA,CAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,CAAA;AAAA,IAC/B;AAAA,EACF;AACA,EAAA,OAAO,OAAA;AACT;AAEA,SAAS,YAAY,WAAA,EAAoC;AACvD,EAAA,IAAI,gBAAgB,IAAA,EAAM;AACxB,IAAA,OAAO,eAAA;AAAA,EACT;AACA,EAAA,MAAM,OAAA,GAAU,KAAK,GAAA,CAAI,IAAA,CAAK,IAAI,WAAA,EAAa,CAAC,GAAG,gBAAgB,CAAA;AACnE,EAAA,OAAO,aAAA,GAAgB,iBAAiB,OAAA,GAAU,gBAAA,CAAA;AACpD;AAGA,SAAS,YAAY,IAAA,EAA0B;AAC7C,EAAA,IAAI,GAAA,GAAM,CAAA;AACV,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,GAAA,IAAO,KAAK,IAAI,CAAA;AAChB,IAAA,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,IAAA,EAAM,IAAA,CAAK,IAAI,CAAC,CAAA;AAAA,EAClC;AACA,EAAA,IAAI,QAAQ,CAAA,EAAG;AACb,IAAA,OAAO,CAAA;AAAA,EACT;AACA,EAAA,OAAO,CAAA,GAAI,oBAAoB,IAAA,GAAO,GAAA,CAAA;AACxC;AAEA,SAAS,QAAA,CAAS,MAAkB,IAAA,EAA4B;AAC9D,EAAA,IAAI,IAAA,CAAK,WAAW,CAAA,EAAG;AACrB,IAAA,OAAO,CAAA;AAAA,EACT;AACA,EAAA,IAAI,QAAA,GAAW,CAAA;AACf,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,KAAA,MAAW,SAAS,IAAA,EAAM;AACxB,IAAA,QAAA,IAAY,KAAA,CAAM,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AAC1C,IAAA,KAAA,IAAS,KAAA,CAAM,MAAA;AAAA,EACjB;AACA,EAAA,OAAO,CAAA,GAAI,iBAAiB,QAAA,GAAW,KAAA,CAAA;AACzC;AAEA,SAAS,QAAQ,EAAA,EAAyB;AACxC,EAAA,OAAO,EAAE,EAAA,EAAI,KAAA,EAAO,CAAA,EAAG,KAAA,EAAO,gBAAe,EAAE;AACjD;AAEA,SAAS,QAAA,CAAS,SAAuB,KAAA,EAAkC;AACzE,EAAA,IAAI,OAAA,CAAQ,QAAA,CAAS,GAAA,CAAI,KAAA,CAAM,EAAE,CAAA,EAAG;AAClC,IAAA,OAAO,OAAA,CAAQ,MAAM,EAAE,CAAA;AAAA,EACzB;AACA,EAAA,IAAI,CAAC,KAAA,CAAM,SAAA,CAAU,IAAA,CAAK,CAAC,QAAA,KAAa,OAAA,CAAQ,OAAA,CAAQ,GAAA,CAAI,QAAQ,CAAC,CAAA,EAAG;AACtE,IAAA,OAAO,OAAA,CAAQ,MAAM,EAAE,CAAA;AAAA,EACzB;AACA,EAAA,MAAM,WAAA,GAAc,OAAA,CAAQ,SAAA,GAAY,MAAA,CAAO,MAAM,IAAI,CAAA;AACzD,EAAA,IAAI,gBAAgB,CAAA,EAAG;AACrB,IAAA,OAAO,OAAA,CAAQ,MAAM,EAAE,CAAA;AAAA,EACzB;AAEA,EAAA,MAAM,IAAA,GAAO,WAAA,CAAY,KAAA,CAAM,IAAI,CAAA,GAAI,WAAA,CAAY,KAAA,CAAM,WAAW,CAAA,GAAI,QAAA,CAAS,KAAA,CAAM,IAAA,EAAM,QAAQ,IAAI,CAAA;AACzG,EAAA,MAAM,QAAQ,cAAA,EAAe;AAC7B,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,KAAA,MAAW,QAAQ,IAAA,EAAM;AACvB,IAAA,MAAM,IAAA,GAAQ,QAAQ,KAAA,CAAM,IAAI,IAAI,KAAA,CAAM,IAAA,CAAK,IAAI,CAAA,GAAI,WAAA,GAAe,IAAA;AACtE,IAAA,KAAA,CAAM,IAAI,CAAA,GAAI,IAAA;AACd,IAAA,KAAA,IAAS,IAAA;AAAA,EACX;AACA,EAAA,OAAO,EAAE,EAAA,EAAI,KAAA,CAAM,EAAA,EAAI,OAAO,KAAA,EAAM;AACtC;AAOO,SAAS,YAAA,CAAa,KAAA,EAAmB,OAAA,EAAyB,IAAA,EAAmC;AAC1G,EAAA,MAAM,OAAA,GAAwB;AAAA,IAC5B,KAAA;AAAA,IACA,SAAA,EAAW,OAAO,KAAK,CAAA;AAAA,IACvB,UAAU,IAAA,CAAK,QAAA;AAAA,IACf,OAAA,EAAS,IAAI,GAAA,CAAI,IAAA,CAAK,gBAAgB,CAAA;AAAA,IACtC,IAAA,EAAM,WAAA,CAAY,IAAA,CAAK,IAAI;AAAA,GAC7B;AACA,EAAA,MAAM,IAAA,GAAO,QAAQ,GAAA,CAAI,CAAC,UAAU,QAAA,CAAS,OAAA,EAAS,KAAK,CAAC,CAAA;AAC5D,EAAA,IAAA,CAAK,KAAK,CAAC,IAAA,EAAM,UAAU,KAAA,CAAM,KAAA,GAAQ,KAAK,KAAK,CAAA;AACnD,EAAA,OAAO,IAAA;AACT;;;AC/GO,SAAS,UAAU,KAAA,EAAyC;AACjE,EAAA,IAAI,IAAA,GAAgB,KAAK,CAAC,CAAA;AAC1B,EAAA,IAAI,SAAA,GAAY,CAAA,QAAA;AAChB,EAAA,KAAA,MAAW,QAAQ,UAAA,EAAY;AAC7B,IAAA,IAAI,KAAA,CAAM,IAAI,CAAA,GAAI,SAAA,EAAW;AAC3B,MAAA,SAAA,GAAY,MAAM,IAAI,CAAA;AACtB,MAAA,IAAA,GAAO,IAAA;AAAA,IACT;AAAA,EACF;AACA,EAAA,OAAO,IAAA;AACT;AAMO,SAAS,SAAA,CAAU,MAAA,EAAuB,KAAA,EAAe,aAAA,EAAsC;AACpG,EAAA,MAAM,UAAA,uBAAiB,GAAA,EAAqB;AAC5C,EAAA,MAAM,MAAqB,EAAC;AAC5B,EAAA,KAAA,MAAW,OAAO,MAAA,EAAQ;AACxB,IAAA,IAAI,GAAA,CAAI,UAAU,KAAA,EAAO;AACvB,MAAA;AAAA,IACF;AACA,IAAA,MAAM,OAAA,GAAU,SAAA,CAAU,GAAA,CAAI,KAAK,CAAA;AACnC,IAAA,MAAM,KAAA,GAAQ,UAAA,CAAW,GAAA,CAAI,OAAO,CAAA,IAAK,CAAA;AACzC,IAAA,IAAI,SAAS,aAAA,EAAe;AAC1B,MAAA;AAAA,IACF;AACA,IAAA,UAAA,CAAW,GAAA,CAAI,OAAA,EAAS,KAAA,GAAQ,CAAC,CAAA;AACjC,IAAA,GAAA,CAAI,KAAK,GAAG,CAAA;AAAA,EACd;AACA,EAAA,OAAO,GAAA;AACT;;;AChCA,IAAM,cAAA,GAAiB,CAAA;AAEvB,SAAS,cAAA,CAAe,MAAkB,KAAA,EAAoC;AAC5E,EAAA,IAAI,IAAA,GAAsB,IAAA;AAC1B,EAAA,IAAI,cAAA,GAAiB,CAAA,QAAA;AACrB,EAAA,KAAA,MAAW,SAAS,KAAA,EAAO;AACzB,IAAA,MAAM,UAAA,GAAa,MAAA,CAAO,IAAA,EAAM,KAAA,CAAM,IAAI,CAAA;AAC1C,IAAA,IAAI,aAAa,cAAA,EAAgB;AAC/B,MAAA,cAAA,GAAiB,UAAA;AACjB,MAAA,IAAA,GAAO,KAAA,CAAM,EAAA;AAAA,IACf;AAAA,EACF;AACA,EAAA,OAAO,IAAA;AACT;AAUO,SAAS,OAAA,CAAQ,KAAA,EAAmB,KAAA,EAAqB,KAAA,EAAkC;AAChG,EAAA,MAAM,IAAA,GAAO,YAAA,CAAa,KAAA,EAAO,CAAC,KAAK,CAAA,EAAG;AAAA,IACxC,QAAA,sBAAc,GAAA,EAAY;AAAA,IAC1B,kBAAkB,KAAA,CAAM;AAAA,GACzB,CAAA;AACD,EAAA,MAAM,KAAA,GAAiC,IAAA,CAAK,CAAC,CAAA,EAAG,SAAS,cAAA,EAAe;AACxE,EAAA,MAAM,OAAA,GAAU,KAAK,MAAA,CAAO,CAAC,SAAS,KAAA,CAAM,IAAI,CAAA,GAAI,CAAC,CAAA,CAClD,IAAA,CAAK,CAAC,IAAA,EAAM,KAAA,KAAU,KAAA,CAAM,KAAK,CAAA,GAAI,KAAA,CAAM,IAAI,CAAC,CAAA,CAChD,KAAA,CAAM,CAAA,EAAG,cAAc,CAAA;AAE1B,EAAA,OAAO;AAAA,IACL,OAAA;AAAA,IACA,cAAA,EAAgB,cAAA,CAAe,KAAA,CAAM,IAAA,EAAM,KAAK,CAAA;AAAA,IAChD,WAAA,EAAa,MAAM,SAAA,CAAU,MAAA,KAAW,IAAI,KAAA,CAAM,SAAA,CAAU,CAAC,CAAA,IAAK,IAAA,GAAO;AAAA,GAC3E;AACF","file":"index.mjs","sourcesContent":["/**\n * The canonical axis list. 18 genre axes followed by 8 structure-and-feel axes.\n * The ORDER is part of the contract: everything downstream that talks about\n * \"genre clusters\" means the first GENRE_AXIS_COUNT entries of this array.\n */\nexport const AXES = [\n // genre (18)\n 'shooter', 'action', 'rpg', 'jrpg', 'strategy', 'simulation', 'racing', 'sports', 'platformer',\n 'puzzle', 'horror', 'adventure', 'roguelike', 'fighting', 'metroidvania', 'soulslike', 'mmo', 'survival',\n // structure and feel (8)\n 'sessionLength', 'difficulty', 'narrative', 'coop', 'competitive', 'relaxing', 'replayability', 'artForward',\n] as const;\n\nexport type AxisKey = typeof AXES[number];\n\n/** Every value is 0..1. */\nexport type AxisVector = Record<AxisKey, number>;\n\nexport const GENRE_AXIS_COUNT = 18;\n\n/** The genre half of AXES - what `diversify` clusters on. */\nexport const GENRE_AXES: readonly AxisKey[] = AXES.slice(0, GENRE_AXIS_COUNT);\n\nexport function zeroAxisVector(): AxisVector {\n const out = {} as AxisVector;\n for (const axis of AXES) {\n out[axis] = 0;\n }\n return out;\n}\n\nexport function l2Norm(vector: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += vector[axis] * vector[axis];\n }\n return Math.sqrt(sum);\n}\n\nexport function dot(left: AxisVector, right: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += left[axis] * right[axis];\n }\n return sum;\n}\n\n/** Cosine similarity, defined as 0 (not NaN) when either side has no magnitude. */\nexport function cosine(left: AxisVector, right: AxisVector): number {\n const denominator = l2Norm(left) * l2Norm(right);\n if (denominator === 0) {\n return 0;\n }\n return dot(left, right) / denominator;\n}\n","import { AXES, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisVector } from './axes';\nimport type { OwnedTitle, QuizAnswer } from './types';\n\nconst RECENCY_MULTIPLIER = 2.0;\nconst OWNERSHIP_PLAYED = 1.0;\nconst OWNERSHIP_UNPLAYED = 0.15;\n/** Below this, a title counts as owned-but-unplayed; log1p would score it 0 and erase it. */\nconst PLAYED_HOURS_THRESHOLD = 0.5;\nconst UNPLAYED_BASE = 1;\n\nconst VERDICT_MULTIPLIER: Record<QuizAnswer['verdict'], number> = {\n loved: 2,\n liked: 1.25,\n bounced: 0.25,\n never: 0,\n};\n\n/**\n * weight = log1p(hours) * recency * ownership.\n *\n * recency is 2.0 when the title was played recently, else 1.0.\n * ownership is 1.0 when hours > 0.5, else 0.15.\n * For an unplayed title log1p(hours) is 0, which would erase the title entirely,\n * so the log term is replaced by UNPLAYED_BASE and only the 0.15 ownership weight remains.\n */\nexport function titleWeight(title: OwnedTitle): number {\n const played = title.hours > PLAYED_HOURS_THRESHOLD;\n const base = played ? Math.log1p(title.hours) : UNPLAYED_BASE;\n const recency = title.playedRecently ? RECENCY_MULTIPLIER : 1;\n const ownership = played ? OWNERSHIP_PLAYED : OWNERSHIP_UNPLAYED;\n return base * recency * ownership;\n}\n\n/**\n * Quiz answers modulate the weight of the OWNED title they name. An answer whose\n * titleId is not in the library is ignored - the vector is built from what the user\n * actually has, and a verdict on something they do not own carries no axes to add.\n */\nexport function buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector {\n const verdicts = new Map<string, number>();\n for (const answer of answers ?? []) {\n verdicts.set(answer.titleId, VERDICT_MULTIPLIER[answer.verdict]);\n }\n\n const accumulator = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title) * (verdicts.get(title.id) ?? 1);\n if (weight === 0) {\n continue;\n }\n for (const axis of AXES) {\n accumulator[axis] += weight * title.axes[axis];\n }\n }\n\n const norm = l2Norm(accumulator);\n if (norm === 0) {\n return accumulator;\n }\n for (const axis of AXES) {\n accumulator[axis] /= norm;\n }\n return accumulator;\n}\n","import { AXES, zeroAxisVector } from './axes';\nimport type { AxisKey } from './axes';\nimport { titleWeight } from './buildTasteVector';\nimport type { OwnedTitle } from './types';\n\n/** Weighted evidence at which an axis reaches ~63% confidence. */\nconst CONFIDENCE_SCALE = 4;\n\n/**\n * How much the library actually tells us about each axis, 0..1.\n * An axis no owned title expresses has zero evidence and therefore zero confidence -\n * which is what `selectQuizCards` targets.\n */\nexport function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number> {\n const evidence = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title);\n for (const axis of AXES) {\n evidence[axis] += weight * title.axes[axis];\n }\n }\n\n const confidence = zeroAxisVector();\n for (const axis of AXES) {\n confidence[axis] = 1 - Math.exp(-evidence[axis] / CONFIDENCE_SCALE);\n }\n return confidence;\n}\n","import { AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { CatalogTitle } from './types';\n\nconst NO_OWNED_IDS: ReadonlySet<string> = new Set<string>();\n\n/**\n * Information gain: how much of this title's axis mass sits on the axes we are LEAST\n * confident about. A card that only expresses axes we already understand teaches nothing.\n */\nfunction informationGain(title: CatalogTitle, confidence: Record<AxisKey, number>): number {\n let gain = 0;\n for (const axis of AXES) {\n gain += (1 - confidence[axis]) * title.axes[axis];\n }\n return gain;\n}\n\n/**\n * `ownedIds` is optional and defaults to empty. It is a parameter rather than a\n * pre-filter on `catalog` so a caller cannot forget it and quiz the user on games\n * they already have.\n */\nexport function selectQuizCards(\n catalog: CatalogTitle[],\n conf: Record<AxisKey, number>,\n count: number,\n ownedIds: ReadonlySet<string> = NO_OWNED_IDS,\n): string[] {\n if (count <= 0) {\n return [];\n }\n return catalog\n .filter((title) => !ownedIds.has(title.id))\n .map((title) => ({ id: title.id, gain: informationGain(title, conf) }))\n .sort((left, right) => right.gain - left.gain)\n .slice(0, count)\n .map((candidate) => candidate.id);\n}\n","import { AXES, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisKey, AxisVector } from './axes';\nimport type { CatalogTitle, ScoreOptions, ScoredTitle } from './types';\n\n/** Applied when a title has no critic score at all - absence is neutral, not bad. */\nconst NEUTRAL_QUALITY = 0.8;\nconst QUALITY_FLOOR = 0.6;\nconst QUALITY_RANGE = 0.4;\nconst CRITIC_SCORE_MAX = 100;\n/** How far a one-note title is discounted against a broad one. */\nconst NOVELTY_STRENGTH = 0.2;\n/** Maximum lift a fully matched mood can apply. */\nconst MOOD_STRENGTH = 0.5;\n\ninterface MoodWeight { axis: AxisKey; weight: number }\n\ninterface ScoreContext {\n taste: AxisVector;\n tasteNorm: number;\n ownedIds: Set<string>;\n allowed: Set<string>;\n mood: MoodWeight[];\n}\n\nfunction moodWeights(mood: Partial<AxisVector> | undefined): MoodWeight[] {\n if (mood === undefined) {\n return [];\n }\n const weights: MoodWeight[] = [];\n for (const axis of AXES) {\n const weight = mood[axis];\n if (weight !== undefined && weight > 0) {\n weights.push({ axis, weight });\n }\n }\n return weights;\n}\n\nfunction qualityTerm(criticScore: number | null): number {\n if (criticScore === null) {\n return NEUTRAL_QUALITY;\n }\n const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);\n return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);\n}\n\n/** Discounts a title whose axis mass is concentrated on a single axis. */\nfunction noveltyTerm(axes: AxisVector): number {\n let sum = 0;\n let peak = 0;\n for (const axis of AXES) {\n sum += axes[axis];\n peak = Math.max(peak, axes[axis]);\n }\n if (sum === 0) {\n return 1;\n }\n return 1 - NOVELTY_STRENGTH * (peak / sum);\n}\n\nfunction moodTerm(axes: AxisVector, mood: MoodWeight[]): number {\n if (mood.length === 0) {\n return 1;\n }\n let weighted = 0;\n let total = 0;\n for (const entry of mood) {\n weighted += entry.weight * axes[entry.axis];\n total += entry.weight;\n }\n return 1 + MOOD_STRENGTH * (weighted / total);\n}\n\nfunction zeroRow(id: string): ScoredTitle {\n return { id, score: 0, terms: zeroAxisVector() };\n}\n\nfunction scoreOne(context: ScoreContext, title: CatalogTitle): ScoredTitle {\n if (context.ownedIds.has(title.id)) {\n return zeroRow(title.id);\n }\n if (!title.platforms.some((platform) => context.allowed.has(platform))) {\n return zeroRow(title.id);\n }\n const denominator = context.tasteNorm * l2Norm(title.axes);\n if (denominator === 0) {\n return zeroRow(title.id);\n }\n\n const gain = noveltyTerm(title.axes) * qualityTerm(title.criticScore) * moodTerm(title.axes, context.mood);\n const terms = zeroAxisVector();\n let score = 0;\n for (const axis of AXES) {\n const term = (context.taste[axis] * title.axes[axis] / denominator) * gain;\n terms[axis] = term;\n score += term;\n }\n return { id: title.id, score, terms };\n}\n\n/**\n * score = cosine(taste, title) * platformGate * novelty * quality * mood.\n * `terms` is the per-axis decomposition of that product, so the terms sum to the score.\n * Rows come back sorted by score, descending.\n */\nexport function scoreCatalog(taste: AxisVector, catalog: CatalogTitle[], opts: ScoreOptions): ScoredTitle[] {\n const context: ScoreContext = {\n taste,\n tasteNorm: l2Norm(taste),\n ownedIds: opts.ownedIds,\n allowed: new Set(opts.allowedPlatforms),\n mood: moodWeights(opts.mood),\n };\n const rows = catalog.map((title) => scoreOne(context, title));\n rows.sort((left, right) => right.score - left.score);\n return rows;\n}\n","import { AXES, GENRE_AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { ScoredTitle } from './types';\n\n/** The genre axis carrying the largest share of a row's score. */\nexport function clusterOf(terms: Record<AxisKey, number>): AxisKey {\n let best: AxisKey = AXES[0];\n let bestValue = -Infinity;\n for (const axis of GENRE_AXES) {\n if (terms[axis] > bestValue) {\n bestValue = terms[axis];\n best = axis;\n }\n }\n return best;\n}\n\n/**\n * Greedy re-rank over the incoming order: keep the highest-scoring rows but never let\n * one genre cluster take more than `maxPerCluster` slots. Relative order is preserved.\n */\nexport function diversify(scored: ScoredTitle[], limit: number, maxPerCluster: number): ScoredTitle[] {\n const perCluster = new Map<AxisKey, number>();\n const out: ScoredTitle[] = [];\n for (const row of scored) {\n if (out.length >= limit) {\n break;\n }\n const cluster = clusterOf(row.terms);\n const taken = perCluster.get(cluster) ?? 0;\n if (taken >= maxPerCluster) {\n continue;\n }\n perCluster.set(cluster, taken + 1);\n out.push(row);\n }\n return out;\n}\n","import { AXES, cosine, zeroAxisVector } from './axes';\nimport type { AxisKey, AxisVector } from './axes';\nimport { scoreCatalog } from './score';\nimport type { CatalogTitle, Explanation, OwnedTitle } from './types';\n\nconst TOP_AXIS_COUNT = 3;\n\nfunction nearestOwnedId(axes: AxisVector, owned: OwnedTitle[]): string | null {\n let best: string | null = null;\n let bestSimilarity = -Infinity;\n for (const title of owned) {\n const similarity = cosine(axes, title.axes);\n if (similarity > bestSimilarity) {\n bestSimilarity = similarity;\n best = title.id;\n }\n }\n return best;\n}\n\n/**\n * The axes come from the REAL scoring terms: `explain` runs the title back through\n * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can\n * never disagree with the ranking it explains.\n *\n * `gapPlatform` names the platform of a title that exists on exactly one - the case\n * where owning that platform is the thing standing between the user and the game.\n */\nexport function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation {\n const rows = scoreCatalog(taste, [title], {\n ownedIds: new Set<string>(),\n allowedPlatforms: title.platforms,\n });\n const terms: Record<AxisKey, number> = rows[0]?.terms ?? zeroAxisVector();\n const topAxes = AXES.filter((axis) => terms[axis] > 0)\n .sort((left, right) => terms[right] - terms[left])\n .slice(0, TOP_AXIS_COUNT);\n\n return {\n topAxes,\n nearestOwnedId: nearestOwnedId(title.axes, owned),\n gapPlatform: title.platforms.length === 1 ? title.platforms[0] ?? null : null,\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/axes.ts","../src/buildTasteVector.ts","../src/confidence.ts","../src/selectQuizCards.ts","../src/score.ts","../src/diversify.ts","../src/explain.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * The canonical axis list. 18 genre axes followed by 8 structure-and-feel axes.\n * The ORDER is part of the contract: everything downstream that talks about\n * \"genre clusters\" means the first GENRE_AXIS_COUNT entries of this array.\n */\nexport const AXES = [\n // genre (18)\n 'shooter', 'action', 'rpg', 'jrpg', 'strategy', 'simulation', 'racing', 'sports', 'platformer',\n 'puzzle', 'horror', 'adventure', 'roguelike', 'fighting', 'metroidvania', 'soulslike', 'mmo', 'survival',\n // structure and feel (8)\n 'sessionLength', 'difficulty', 'narrative', 'coop', 'competitive', 'relaxing', 'replayability', 'artForward',\n] as const;\n\nexport type AxisKey = typeof AXES[number];\n\n/** Every value is 0..1. */\nexport type AxisVector = Record<AxisKey, number>;\n\nexport const GENRE_AXIS_COUNT = 18;\n\n/** The genre half of AXES - what `diversify` clusters on. */\nexport const GENRE_AXES: readonly AxisKey[] = AXES.slice(0, GENRE_AXIS_COUNT);\n\nexport function zeroAxisVector(): AxisVector {\n const out = {} as AxisVector;\n for (const axis of AXES) {\n out[axis] = 0;\n }\n return out;\n}\n\nexport function l2Norm(vector: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += vector[axis] * vector[axis];\n }\n return Math.sqrt(sum);\n}\n\nexport function dot(left: AxisVector, right: AxisVector): number {\n let sum = 0;\n for (const axis of AXES) {\n sum += left[axis] * right[axis];\n }\n return sum;\n}\n\n/** Cosine similarity, defined as 0 (not NaN) when either side has no magnitude. */\nexport function cosine(left: AxisVector, right: AxisVector): number {\n const denominator = l2Norm(left) * l2Norm(right);\n if (denominator === 0) {\n return 0;\n }\n return dot(left, right) / denominator;\n}\n","import { AXES, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisVector } from './axes';\nimport type { OwnedTitle, QuizAnswer } from './types';\n\nconst RECENCY_MULTIPLIER = 2.0;\nconst OWNERSHIP_PLAYED = 1.0;\nconst OWNERSHIP_UNPLAYED = 0.15;\n/** Below this, a title counts as owned-but-unplayed; log1p would score it 0 and erase it. */\nconst PLAYED_HOURS_THRESHOLD = 0.5;\nconst UNPLAYED_BASE = 1;\n\nconst VERDICT_MULTIPLIER: Record<QuizAnswer['verdict'], number> = {\n loved: 2,\n liked: 1.25,\n bounced: 0.25,\n never: 0,\n};\n\n/**\n * weight = log1p(hours) * recency * ownership.\n *\n * recency is 2.0 when the title was played recently, else 1.0.\n * ownership is 1.0 when hours > 0.5, else 0.15.\n * For an unplayed title log1p(hours) is 0, which would erase the title entirely,\n * so the log term is replaced by UNPLAYED_BASE and only the 0.15 ownership weight remains.\n */\nexport function titleWeight(title: OwnedTitle): number {\n const played = title.hours > PLAYED_HOURS_THRESHOLD;\n const base = played ? Math.log1p(title.hours) : UNPLAYED_BASE;\n const recency = title.playedRecently ? RECENCY_MULTIPLIER : 1;\n const ownership = played ? OWNERSHIP_PLAYED : OWNERSHIP_UNPLAYED;\n return base * recency * ownership;\n}\n\n/**\n * Quiz answers modulate the weight of the OWNED title they name. An answer whose\n * titleId is not in the library is ignored - the vector is built from what the user\n * actually has, and a verdict on something they do not own carries no axes to add.\n */\nexport function buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector {\n const verdicts = new Map<string, number>();\n for (const answer of answers ?? []) {\n verdicts.set(answer.titleId, VERDICT_MULTIPLIER[answer.verdict]);\n }\n\n const accumulator = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title) * (verdicts.get(title.id) ?? 1);\n if (weight === 0) {\n continue;\n }\n for (const axis of AXES) {\n accumulator[axis] += weight * title.axes[axis];\n }\n }\n\n const norm = l2Norm(accumulator);\n if (norm === 0) {\n return accumulator;\n }\n for (const axis of AXES) {\n accumulator[axis] /= norm;\n }\n return accumulator;\n}\n","import { AXES, zeroAxisVector } from './axes';\nimport type { AxisKey } from './axes';\nimport { titleWeight } from './buildTasteVector';\nimport type { OwnedTitle } from './types';\n\n/** Weighted evidence at which an axis reaches ~63% confidence. */\nconst CONFIDENCE_SCALE = 4;\n\n/**\n * How much the library actually tells us about each axis, 0..1.\n * An axis no owned title expresses has zero evidence and therefore zero confidence -\n * which is what `selectQuizCards` targets.\n */\nexport function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number> {\n const evidence = zeroAxisVector();\n for (const title of owned) {\n const weight = titleWeight(title);\n for (const axis of AXES) {\n evidence[axis] += weight * title.axes[axis];\n }\n }\n\n const confidence = zeroAxisVector();\n for (const axis of AXES) {\n confidence[axis] = 1 - Math.exp(-evidence[axis] / CONFIDENCE_SCALE);\n }\n return confidence;\n}\n","import { AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { CatalogTitle } from './types';\n\nconst NO_OWNED_IDS: ReadonlySet<string> = new Set<string>();\n\n/**\n * Information gain: how much of this title's axis mass sits on the axes we are LEAST\n * confident about. A card that only expresses axes we already understand teaches nothing.\n */\nfunction informationGain(title: CatalogTitle, confidence: Record<AxisKey, number>): number {\n let gain = 0;\n for (const axis of AXES) {\n gain += (1 - confidence[axis]) * title.axes[axis];\n }\n return gain;\n}\n\n/**\n * `ownedIds` is optional and defaults to empty. It is a parameter rather than a\n * pre-filter on `catalog` so a caller cannot forget it and quiz the user on games\n * they already have.\n */\nexport function selectQuizCards(\n catalog: CatalogTitle[],\n conf: Record<AxisKey, number>,\n count: number,\n ownedIds: ReadonlySet<string> = NO_OWNED_IDS,\n): string[] {\n if (count <= 0) {\n return [];\n }\n return catalog\n .filter((title) => !ownedIds.has(title.id))\n .map((title) => ({ id: title.id, gain: informationGain(title, conf) }))\n .sort((left, right) => right.gain - left.gain)\n .slice(0, count)\n .map((candidate) => candidate.id);\n}\n","import { AXES, cosine, l2Norm, zeroAxisVector } from './axes';\nimport type { AxisKey, AxisVector } from './axes';\nimport { titleWeight } from './buildTasteVector';\nimport type { CatalogTitle, OwnedTitle, ScoreFactors, ScoreOptions, ScoredTitle } from './types';\n\n/** Applied when a title has no critic score at all - absence is neutral, not bad. */\nconst NEUTRAL_QUALITY = 0.8;\nconst QUALITY_FLOOR = 0.6;\nconst QUALITY_RANGE = 0.4;\nconst CRITIC_SCORE_MAX = 100;\n/** How far a one-note title is discounted against a broad one. */\nconst BREADTH_STRENGTH = 0.2;\n/** How many owned titles the novelty discount looks at, ranked by titleWeight. */\nconst NOVELTY_TOP_OWNED = 5;\n/** A title identical to a top-5 favourite keeps 1 - 0.65 = 0.35 of its score. */\nconst NOVELTY_MAX_DISCOUNT = 0.65;\n/** Squared, so a loose resemblance is barely touched and only a near-clone is hit hard. */\nconst NOVELTY_SIMILARITY_EXPONENT = 2;\n/** Maximum lift a fully matched mood can apply. */\nconst MOOD_STRENGTH = 0.5;\n\ninterface MoodWeight { axis: AxisKey; weight: number }\n\ninterface ScoreContext {\n taste: AxisVector;\n tasteNorm: number;\n ownedIds: Set<string>;\n allowed: Set<string>;\n mood: MoodWeight[];\n topOwned: readonly OwnedTitle[];\n}\n\nfunction moodWeights(mood: Partial<AxisVector> | undefined): MoodWeight[] {\n if (mood === undefined) {\n return [];\n }\n const weights: MoodWeight[] = [];\n for (const axis of AXES) {\n const weight = mood[axis];\n if (weight !== undefined && weight > 0) {\n weights.push({ axis, weight });\n }\n }\n return weights;\n}\n\nfunction qualityTerm(criticScore: number | null): number {\n if (criticScore === null) {\n return NEUTRAL_QUALITY;\n }\n const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);\n return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);\n}\n\n/** Discounts a title whose axis mass is concentrated on a single axis. */\nfunction topOwnedByWeight(owned: readonly OwnedTitle[] | undefined): readonly OwnedTitle[] {\n if (owned === undefined || owned.length === 0) {\n return [];\n }\n return [...owned].sort((left, right) => titleWeight(right) - titleWeight(left)).slice(0, NOVELTY_TOP_OWNED);\n}\n\n/**\n * Spec 4.4: reduced if near-identical to a top-5 owned title.\n * novelty = 1 - 0.65 * max(cosine to a top-5 owned title, clamped 0..1) ^ 2.\n * No `ownedTitles` means no evidence of a clone, so the factor is exactly 1 - never 0, never NaN.\n */\nfunction noveltyTerm(axes: AxisVector, topOwned: readonly OwnedTitle[]): number {\n let peak = 0;\n for (const owned of topOwned) {\n peak = Math.max(peak, cosine(axes, owned.axes));\n }\n const clamped = Math.min(Math.max(peak, 0), 1);\n return 1 - NOVELTY_MAX_DISCOUNT * Math.pow(clamped, NOVELTY_SIMILARITY_EXPONENT);\n}\n\nfunction breadthTerm(axes: AxisVector): number {\n let sum = 0;\n let peak = 0;\n for (const axis of AXES) {\n sum += axes[axis];\n peak = Math.max(peak, axes[axis]);\n }\n if (sum === 0) {\n return 1;\n }\n return 1 - BREADTH_STRENGTH * (peak / sum);\n}\n\nfunction moodTerm(axes: AxisVector, mood: MoodWeight[]): number {\n if (mood.length === 0) {\n return 1;\n }\n let weighted = 0;\n let total = 0;\n for (const entry of mood) {\n weighted += entry.weight * axes[entry.axis];\n total += entry.weight;\n }\n return 1 + MOOD_STRENGTH * (weighted / total);\n}\n\nconst NO_FACTORS: ScoreFactors = { breadth: 0, novelty: 0, quality: 0, mood: 0 };\n\nfunction zeroRow(id: string): ScoredTitle {\n return { id, score: 0, terms: zeroAxisVector(), factors: { ...NO_FACTORS } };\n}\n\nfunction scoreOne(context: ScoreContext, title: CatalogTitle): ScoredTitle {\n if (context.ownedIds.has(title.id)) {\n return zeroRow(title.id);\n }\n if (!title.platforms.some((platform) => context.allowed.has(platform))) {\n return zeroRow(title.id);\n }\n const denominator = context.tasteNorm * l2Norm(title.axes);\n if (denominator === 0) {\n return zeroRow(title.id);\n }\n\n const factors: ScoreFactors = {\n breadth: breadthTerm(title.axes),\n novelty: noveltyTerm(title.axes, context.topOwned),\n quality: qualityTerm(title.criticScore),\n mood: moodTerm(title.axes, context.mood),\n };\n const gain = factors.breadth * factors.novelty * factors.quality * factors.mood;\n const terms = zeroAxisVector();\n let score = 0;\n for (const axis of AXES) {\n const term = (context.taste[axis] * title.axes[axis] / denominator) * gain;\n terms[axis] = term;\n score += term;\n }\n return { id: title.id, score, terms, factors };\n}\n\n/**\n * score = cosine(taste, title) * platformGate * breadth * novelty * quality * mood.\n * `terms` is the per-axis decomposition of that product, so the terms sum to the score.\n * Rows come back sorted by score, descending.\n */\nexport function scoreCatalog(taste: AxisVector, catalog: CatalogTitle[], opts: ScoreOptions): ScoredTitle[] {\n const context: ScoreContext = {\n taste,\n tasteNorm: l2Norm(taste),\n ownedIds: opts.ownedIds,\n allowed: new Set(opts.allowedPlatforms),\n mood: moodWeights(opts.mood),\n topOwned: topOwnedByWeight(opts.ownedTitles),\n };\n const rows = catalog.map((title) => scoreOne(context, title));\n rows.sort((left, right) => right.score - left.score);\n return rows;\n}\n","import { AXES, GENRE_AXES } from './axes';\nimport type { AxisKey } from './axes';\nimport type { ScoredTitle } from './types';\n\n/** The genre axis carrying the largest share of a row's score. */\nexport function clusterOf(terms: Record<AxisKey, number>): AxisKey {\n let best: AxisKey = AXES[0];\n let bestValue = -Infinity;\n for (const axis of GENRE_AXES) {\n if (terms[axis] > bestValue) {\n bestValue = terms[axis];\n best = axis;\n }\n }\n return best;\n}\n\n/**\n * Greedy re-rank over the incoming order: keep the highest-scoring rows but never let\n * one genre cluster take more than `maxPerCluster` slots. Relative order is preserved.\n */\nexport function diversify(scored: ScoredTitle[], limit: number, maxPerCluster: number): ScoredTitle[] {\n const perCluster = new Map<AxisKey, number>();\n const out: ScoredTitle[] = [];\n for (const row of scored) {\n if (out.length >= limit) {\n break;\n }\n const cluster = clusterOf(row.terms);\n const taken = perCluster.get(cluster) ?? 0;\n if (taken >= maxPerCluster) {\n continue;\n }\n perCluster.set(cluster, taken + 1);\n out.push(row);\n }\n return out;\n}\n","import { AXES, cosine, zeroAxisVector } from './axes';\r\nimport type { AxisKey, AxisVector } from './axes';\r\nimport { scoreCatalog } from './score';\r\nimport type { CatalogTitle, Explanation, OwnedTitle } from './types';\r\n\r\nconst TOP_AXIS_COUNT = 3;\r\n\r\nfunction nearestOwnedId(axes: AxisVector, owned: OwnedTitle[]): string | null {\r\n let best: string | null = null;\r\n let bestSimilarity = -Infinity;\r\n for (const title of owned) {\r\n const similarity = cosine(axes, title.axes);\r\n if (similarity > bestSimilarity) {\r\n bestSimilarity = similarity;\r\n best = title.id;\r\n }\r\n }\r\n return best;\r\n}\r\n\r\n/**\r\n * The axes come from the REAL scoring terms: `explain` runs the title back through\r\n * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can\r\n * never disagree with the ranking it explains.\r\n *\r\n * `gapPlatform` describes the TITLE, not the user: platforms[0] for a single-platform\r\n * exclusive, null otherwise. OwnedTitle carries no platform, so this cannot know which\r\n * platforms a user has - whether that exclusive is a gap for them is the caller's call.\r\n */\r\nexport function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation {\r\n const rows = scoreCatalog(taste, [title], {\r\n ownedIds: new Set<string>(),\r\n allowedPlatforms: title.platforms,\r\n });\r\n const terms: Record<AxisKey, number> = rows[0]?.terms ?? zeroAxisVector();\r\n const topAxes = AXES.filter((axis) => terms[axis] > 0)\r\n .sort((left, right) => terms[right] - terms[left])\r\n .slice(0, TOP_AXIS_COUNT);\r\n\r\n return {\r\n topAxes,\r\n nearestOwnedId: nearestOwnedId(title.axes, owned),\r\n gapPlatform: title.platforms.length === 1 ? title.platforms[0] ?? null : null,\r\n };\r\n}\r\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dloizides/taste-engine",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Domain-agnostic weighted-vector taste modelling: build a normalised 26-axis taste vector from a played library, measure per-axis confidence, pick quiz cards that reduce uncertainty, score and diversify a catalogue, and explain a recommendation from its real scoring terms. Pure TypeScript, zero I/O, zero deps.",
5
5
  "keywords": [
6
6
  "recommender",