@dloizides/taste-engine 0.1.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 ADDED
@@ -0,0 +1,18 @@
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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 dloizides
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The canonical axis list. 18 genre axes followed by 8 structure-and-feel axes.
3
+ * The ORDER is part of the contract: everything downstream that talks about
4
+ * "genre clusters" means the first GENRE_AXIS_COUNT entries of this array.
5
+ */
6
+ declare const AXES: readonly ["shooter", "action", "rpg", "jrpg", "strategy", "simulation", "racing", "sports", "platformer", "puzzle", "horror", "adventure", "roguelike", "fighting", "metroidvania", "soulslike", "mmo", "survival", "sessionLength", "difficulty", "narrative", "coop", "competitive", "relaxing", "replayability", "artForward"];
7
+ type AxisKey = typeof AXES[number];
8
+ /** Every value is 0..1. */
9
+ type AxisVector = Record<AxisKey, number>;
10
+ declare const GENRE_AXIS_COUNT = 18;
11
+ /** The genre half of AXES - what `diversify` clusters on. */
12
+ declare const GENRE_AXES: readonly AxisKey[];
13
+ declare function zeroAxisVector(): AxisVector;
14
+ declare function l2Norm(vector: AxisVector): number;
15
+ declare function dot(left: AxisVector, right: AxisVector): number;
16
+ /** Cosine similarity, defined as 0 (not NaN) when either side has no magnitude. */
17
+ declare function cosine(left: AxisVector, right: AxisVector): number;
18
+
19
+ interface OwnedTitle {
20
+ id: string;
21
+ hours: number;
22
+ playedRecently: boolean;
23
+ axes: AxisVector;
24
+ }
25
+ interface CatalogTitle {
26
+ id: string;
27
+ axes: AxisVector;
28
+ platforms: string[];
29
+ /** null means "no critic score exists", which scores as neutral - never as zero quality. */
30
+ criticScore: number | null;
31
+ }
32
+ interface QuizAnswer {
33
+ titleId: string;
34
+ verdict: 'loved' | 'liked' | 'bounced' | 'never';
35
+ }
36
+ interface ScoreOptions {
37
+ ownedIds: Set<string>;
38
+ allowedPlatforms: string[];
39
+ mood?: Partial<AxisVector>;
40
+ }
41
+ interface ScoredTitle {
42
+ id: string;
43
+ score: number;
44
+ /** Per-axis decomposition of `score`. The terms sum to `score` exactly. */
45
+ terms: Record<AxisKey, number>;
46
+ }
47
+ interface Explanation {
48
+ topAxes: AxisKey[];
49
+ nearestOwnedId: string | null;
50
+ gapPlatform: string | null;
51
+ }
52
+
53
+ /**
54
+ * weight = log1p(hours) * recency * ownership.
55
+ *
56
+ * recency is 2.0 when the title was played recently, else 1.0.
57
+ * ownership is 1.0 when hours > 0.5, else 0.15.
58
+ * For an unplayed title log1p(hours) is 0, which would erase the title entirely,
59
+ * so the log term is replaced by UNPLAYED_BASE and only the 0.15 ownership weight remains.
60
+ */
61
+ declare function titleWeight(title: OwnedTitle): number;
62
+ /**
63
+ * Quiz answers modulate the weight of the OWNED title they name. An answer whose
64
+ * titleId is not in the library is ignored - the vector is built from what the user
65
+ * actually has, and a verdict on something they do not own carries no axes to add.
66
+ */
67
+ declare function buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector;
68
+
69
+ /**
70
+ * How much the library actually tells us about each axis, 0..1.
71
+ * An axis no owned title expresses has zero evidence and therefore zero confidence -
72
+ * which is what `selectQuizCards` targets.
73
+ */
74
+ declare function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number>;
75
+
76
+ /**
77
+ * `ownedIds` is optional and defaults to empty. It is a parameter rather than a
78
+ * pre-filter on `catalog` so a caller cannot forget it and quiz the user on games
79
+ * they already have.
80
+ */
81
+ declare function selectQuizCards(catalog: CatalogTitle[], conf: Record<AxisKey, number>, count: number, ownedIds?: ReadonlySet<string>): string[];
82
+
83
+ /**
84
+ * score = cosine(taste, title) * platformGate * novelty * quality * mood.
85
+ * `terms` is the per-axis decomposition of that product, so the terms sum to the score.
86
+ * Rows come back sorted by score, descending.
87
+ */
88
+ declare function scoreCatalog(taste: AxisVector, catalog: CatalogTitle[], opts: ScoreOptions): ScoredTitle[];
89
+
90
+ /** The genre axis carrying the largest share of a row's score. */
91
+ declare function clusterOf(terms: Record<AxisKey, number>): AxisKey;
92
+ /**
93
+ * Greedy re-rank over the incoming order: keep the highest-scoring rows but never let
94
+ * one genre cluster take more than `maxPerCluster` slots. Relative order is preserved.
95
+ */
96
+ declare function diversify(scored: ScoredTitle[], limit: number, maxPerCluster: number): ScoredTitle[];
97
+
98
+ /**
99
+ * The axes come from the REAL scoring terms: `explain` runs the title back through
100
+ * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can
101
+ * never disagree with the ranking it explains.
102
+ *
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.
105
+ */
106
+ declare function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation;
107
+
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 };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The canonical axis list. 18 genre axes followed by 8 structure-and-feel axes.
3
+ * The ORDER is part of the contract: everything downstream that talks about
4
+ * "genre clusters" means the first GENRE_AXIS_COUNT entries of this array.
5
+ */
6
+ declare const AXES: readonly ["shooter", "action", "rpg", "jrpg", "strategy", "simulation", "racing", "sports", "platformer", "puzzle", "horror", "adventure", "roguelike", "fighting", "metroidvania", "soulslike", "mmo", "survival", "sessionLength", "difficulty", "narrative", "coop", "competitive", "relaxing", "replayability", "artForward"];
7
+ type AxisKey = typeof AXES[number];
8
+ /** Every value is 0..1. */
9
+ type AxisVector = Record<AxisKey, number>;
10
+ declare const GENRE_AXIS_COUNT = 18;
11
+ /** The genre half of AXES - what `diversify` clusters on. */
12
+ declare const GENRE_AXES: readonly AxisKey[];
13
+ declare function zeroAxisVector(): AxisVector;
14
+ declare function l2Norm(vector: AxisVector): number;
15
+ declare function dot(left: AxisVector, right: AxisVector): number;
16
+ /** Cosine similarity, defined as 0 (not NaN) when either side has no magnitude. */
17
+ declare function cosine(left: AxisVector, right: AxisVector): number;
18
+
19
+ interface OwnedTitle {
20
+ id: string;
21
+ hours: number;
22
+ playedRecently: boolean;
23
+ axes: AxisVector;
24
+ }
25
+ interface CatalogTitle {
26
+ id: string;
27
+ axes: AxisVector;
28
+ platforms: string[];
29
+ /** null means "no critic score exists", which scores as neutral - never as zero quality. */
30
+ criticScore: number | null;
31
+ }
32
+ interface QuizAnswer {
33
+ titleId: string;
34
+ verdict: 'loved' | 'liked' | 'bounced' | 'never';
35
+ }
36
+ interface ScoreOptions {
37
+ ownedIds: Set<string>;
38
+ allowedPlatforms: string[];
39
+ mood?: Partial<AxisVector>;
40
+ }
41
+ interface ScoredTitle {
42
+ id: string;
43
+ score: number;
44
+ /** Per-axis decomposition of `score`. The terms sum to `score` exactly. */
45
+ terms: Record<AxisKey, number>;
46
+ }
47
+ interface Explanation {
48
+ topAxes: AxisKey[];
49
+ nearestOwnedId: string | null;
50
+ gapPlatform: string | null;
51
+ }
52
+
53
+ /**
54
+ * weight = log1p(hours) * recency * ownership.
55
+ *
56
+ * recency is 2.0 when the title was played recently, else 1.0.
57
+ * ownership is 1.0 when hours > 0.5, else 0.15.
58
+ * For an unplayed title log1p(hours) is 0, which would erase the title entirely,
59
+ * so the log term is replaced by UNPLAYED_BASE and only the 0.15 ownership weight remains.
60
+ */
61
+ declare function titleWeight(title: OwnedTitle): number;
62
+ /**
63
+ * Quiz answers modulate the weight of the OWNED title they name. An answer whose
64
+ * titleId is not in the library is ignored - the vector is built from what the user
65
+ * actually has, and a verdict on something they do not own carries no axes to add.
66
+ */
67
+ declare function buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector;
68
+
69
+ /**
70
+ * How much the library actually tells us about each axis, 0..1.
71
+ * An axis no owned title expresses has zero evidence and therefore zero confidence -
72
+ * which is what `selectQuizCards` targets.
73
+ */
74
+ declare function axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number>;
75
+
76
+ /**
77
+ * `ownedIds` is optional and defaults to empty. It is a parameter rather than a
78
+ * pre-filter on `catalog` so a caller cannot forget it and quiz the user on games
79
+ * they already have.
80
+ */
81
+ declare function selectQuizCards(catalog: CatalogTitle[], conf: Record<AxisKey, number>, count: number, ownedIds?: ReadonlySet<string>): string[];
82
+
83
+ /**
84
+ * score = cosine(taste, title) * platformGate * novelty * quality * mood.
85
+ * `terms` is the per-axis decomposition of that product, so the terms sum to the score.
86
+ * Rows come back sorted by score, descending.
87
+ */
88
+ declare function scoreCatalog(taste: AxisVector, catalog: CatalogTitle[], opts: ScoreOptions): ScoredTitle[];
89
+
90
+ /** The genre axis carrying the largest share of a row's score. */
91
+ declare function clusterOf(terms: Record<AxisKey, number>): AxisKey;
92
+ /**
93
+ * Greedy re-rank over the incoming order: keep the highest-scoring rows but never let
94
+ * one genre cluster take more than `maxPerCluster` slots. Relative order is preserved.
95
+ */
96
+ declare function diversify(scored: ScoredTitle[], limit: number, maxPerCluster: number): ScoredTitle[];
97
+
98
+ /**
99
+ * The axes come from the REAL scoring terms: `explain` runs the title back through
100
+ * `scoreCatalog` rather than recomputing a lookalike heuristic, so an explanation can
101
+ * never disagree with the ranking it explains.
102
+ *
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.
105
+ */
106
+ declare function explain(taste: AxisVector, title: CatalogTitle, owned: OwnedTitle[]): Explanation;
107
+
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 };
package/dist/index.js ADDED
@@ -0,0 +1,304 @@
1
+ 'use strict';
2
+
3
+ // src/axes.ts
4
+ var AXES = [
5
+ // genre (18)
6
+ "shooter",
7
+ "action",
8
+ "rpg",
9
+ "jrpg",
10
+ "strategy",
11
+ "simulation",
12
+ "racing",
13
+ "sports",
14
+ "platformer",
15
+ "puzzle",
16
+ "horror",
17
+ "adventure",
18
+ "roguelike",
19
+ "fighting",
20
+ "metroidvania",
21
+ "soulslike",
22
+ "mmo",
23
+ "survival",
24
+ // structure and feel (8)
25
+ "sessionLength",
26
+ "difficulty",
27
+ "narrative",
28
+ "coop",
29
+ "competitive",
30
+ "relaxing",
31
+ "replayability",
32
+ "artForward"
33
+ ];
34
+ var GENRE_AXIS_COUNT = 18;
35
+ var GENRE_AXES = AXES.slice(0, GENRE_AXIS_COUNT);
36
+ function zeroAxisVector() {
37
+ const out = {};
38
+ for (const axis of AXES) {
39
+ out[axis] = 0;
40
+ }
41
+ return out;
42
+ }
43
+ function l2Norm(vector) {
44
+ let sum = 0;
45
+ for (const axis of AXES) {
46
+ sum += vector[axis] * vector[axis];
47
+ }
48
+ return Math.sqrt(sum);
49
+ }
50
+ function dot(left, right) {
51
+ let sum = 0;
52
+ for (const axis of AXES) {
53
+ sum += left[axis] * right[axis];
54
+ }
55
+ return sum;
56
+ }
57
+ function cosine(left, right) {
58
+ const denominator = l2Norm(left) * l2Norm(right);
59
+ if (denominator === 0) {
60
+ return 0;
61
+ }
62
+ return dot(left, right) / denominator;
63
+ }
64
+
65
+ // src/buildTasteVector.ts
66
+ var RECENCY_MULTIPLIER = 2;
67
+ var OWNERSHIP_PLAYED = 1;
68
+ var OWNERSHIP_UNPLAYED = 0.15;
69
+ var PLAYED_HOURS_THRESHOLD = 0.5;
70
+ var UNPLAYED_BASE = 1;
71
+ var VERDICT_MULTIPLIER = {
72
+ loved: 2,
73
+ liked: 1.25,
74
+ bounced: 0.25,
75
+ never: 0
76
+ };
77
+ function titleWeight(title) {
78
+ const played = title.hours > PLAYED_HOURS_THRESHOLD;
79
+ const base = played ? Math.log1p(title.hours) : UNPLAYED_BASE;
80
+ const recency = title.playedRecently ? RECENCY_MULTIPLIER : 1;
81
+ const ownership = played ? OWNERSHIP_PLAYED : OWNERSHIP_UNPLAYED;
82
+ return base * recency * ownership;
83
+ }
84
+ function buildTasteVector(owned, answers) {
85
+ const verdicts = /* @__PURE__ */ new Map();
86
+ for (const answer of answers ?? []) {
87
+ verdicts.set(answer.titleId, VERDICT_MULTIPLIER[answer.verdict]);
88
+ }
89
+ const accumulator = zeroAxisVector();
90
+ for (const title of owned) {
91
+ const weight = titleWeight(title) * (verdicts.get(title.id) ?? 1);
92
+ if (weight === 0) {
93
+ continue;
94
+ }
95
+ for (const axis of AXES) {
96
+ accumulator[axis] += weight * title.axes[axis];
97
+ }
98
+ }
99
+ const norm = l2Norm(accumulator);
100
+ if (norm === 0) {
101
+ return accumulator;
102
+ }
103
+ for (const axis of AXES) {
104
+ accumulator[axis] /= norm;
105
+ }
106
+ return accumulator;
107
+ }
108
+
109
+ // src/confidence.ts
110
+ var CONFIDENCE_SCALE = 4;
111
+ function axisConfidence(owned) {
112
+ const evidence = zeroAxisVector();
113
+ for (const title of owned) {
114
+ const weight = titleWeight(title);
115
+ for (const axis of AXES) {
116
+ evidence[axis] += weight * title.axes[axis];
117
+ }
118
+ }
119
+ const confidence = zeroAxisVector();
120
+ for (const axis of AXES) {
121
+ confidence[axis] = 1 - Math.exp(-evidence[axis] / CONFIDENCE_SCALE);
122
+ }
123
+ return confidence;
124
+ }
125
+
126
+ // src/selectQuizCards.ts
127
+ var NO_OWNED_IDS = /* @__PURE__ */ new Set();
128
+ function informationGain(title, confidence) {
129
+ let gain = 0;
130
+ for (const axis of AXES) {
131
+ gain += (1 - confidence[axis]) * title.axes[axis];
132
+ }
133
+ return gain;
134
+ }
135
+ function selectQuizCards(catalog, conf, count, ownedIds = NO_OWNED_IDS) {
136
+ if (count <= 0) {
137
+ return [];
138
+ }
139
+ return catalog.filter((title) => !ownedIds.has(title.id)).map((title) => ({ id: title.id, gain: informationGain(title, conf) })).sort((left, right) => right.gain - left.gain).slice(0, count).map((candidate) => candidate.id);
140
+ }
141
+
142
+ // src/score.ts
143
+ var NEUTRAL_QUALITY = 0.8;
144
+ var QUALITY_FLOOR = 0.6;
145
+ var QUALITY_RANGE = 0.4;
146
+ var CRITIC_SCORE_MAX = 100;
147
+ var NOVELTY_STRENGTH = 0.2;
148
+ var MOOD_STRENGTH = 0.5;
149
+ function moodWeights(mood) {
150
+ if (mood === void 0) {
151
+ return [];
152
+ }
153
+ const weights = [];
154
+ for (const axis of AXES) {
155
+ const weight = mood[axis];
156
+ if (weight !== void 0 && weight > 0) {
157
+ weights.push({ axis, weight });
158
+ }
159
+ }
160
+ return weights;
161
+ }
162
+ function qualityTerm(criticScore) {
163
+ if (criticScore === null) {
164
+ return NEUTRAL_QUALITY;
165
+ }
166
+ const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);
167
+ return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);
168
+ }
169
+ function noveltyTerm(axes) {
170
+ let sum = 0;
171
+ let peak = 0;
172
+ for (const axis of AXES) {
173
+ sum += axes[axis];
174
+ peak = Math.max(peak, axes[axis]);
175
+ }
176
+ if (sum === 0) {
177
+ return 1;
178
+ }
179
+ return 1 - NOVELTY_STRENGTH * (peak / sum);
180
+ }
181
+ function moodTerm(axes, mood) {
182
+ if (mood.length === 0) {
183
+ return 1;
184
+ }
185
+ let weighted = 0;
186
+ let total = 0;
187
+ for (const entry of mood) {
188
+ weighted += entry.weight * axes[entry.axis];
189
+ total += entry.weight;
190
+ }
191
+ return 1 + MOOD_STRENGTH * (weighted / total);
192
+ }
193
+ function zeroRow(id) {
194
+ return { id, score: 0, terms: zeroAxisVector() };
195
+ }
196
+ function scoreOne(context, title) {
197
+ if (context.ownedIds.has(title.id)) {
198
+ return zeroRow(title.id);
199
+ }
200
+ if (!title.platforms.some((platform) => context.allowed.has(platform))) {
201
+ return zeroRow(title.id);
202
+ }
203
+ const denominator = context.tasteNorm * l2Norm(title.axes);
204
+ if (denominator === 0) {
205
+ return zeroRow(title.id);
206
+ }
207
+ const gain = noveltyTerm(title.axes) * qualityTerm(title.criticScore) * moodTerm(title.axes, context.mood);
208
+ const terms = zeroAxisVector();
209
+ let score = 0;
210
+ for (const axis of AXES) {
211
+ const term = context.taste[axis] * title.axes[axis] / denominator * gain;
212
+ terms[axis] = term;
213
+ score += term;
214
+ }
215
+ return { id: title.id, score, terms };
216
+ }
217
+ function scoreCatalog(taste, catalog, opts) {
218
+ const context = {
219
+ taste,
220
+ tasteNorm: l2Norm(taste),
221
+ ownedIds: opts.ownedIds,
222
+ allowed: new Set(opts.allowedPlatforms),
223
+ mood: moodWeights(opts.mood)
224
+ };
225
+ const rows = catalog.map((title) => scoreOne(context, title));
226
+ rows.sort((left, right) => right.score - left.score);
227
+ return rows;
228
+ }
229
+
230
+ // src/diversify.ts
231
+ function clusterOf(terms) {
232
+ let best = AXES[0];
233
+ let bestValue = -Infinity;
234
+ for (const axis of GENRE_AXES) {
235
+ if (terms[axis] > bestValue) {
236
+ bestValue = terms[axis];
237
+ best = axis;
238
+ }
239
+ }
240
+ return best;
241
+ }
242
+ function diversify(scored, limit, maxPerCluster) {
243
+ const perCluster = /* @__PURE__ */ new Map();
244
+ const out = [];
245
+ for (const row of scored) {
246
+ if (out.length >= limit) {
247
+ break;
248
+ }
249
+ const cluster = clusterOf(row.terms);
250
+ const taken = perCluster.get(cluster) ?? 0;
251
+ if (taken >= maxPerCluster) {
252
+ continue;
253
+ }
254
+ perCluster.set(cluster, taken + 1);
255
+ out.push(row);
256
+ }
257
+ return out;
258
+ }
259
+
260
+ // src/explain.ts
261
+ var TOP_AXIS_COUNT = 3;
262
+ function nearestOwnedId(axes, owned) {
263
+ let best = null;
264
+ let bestSimilarity = -Infinity;
265
+ for (const title of owned) {
266
+ const similarity = cosine(axes, title.axes);
267
+ if (similarity > bestSimilarity) {
268
+ bestSimilarity = similarity;
269
+ best = title.id;
270
+ }
271
+ }
272
+ return best;
273
+ }
274
+ function explain(taste, title, owned) {
275
+ const rows = scoreCatalog(taste, [title], {
276
+ ownedIds: /* @__PURE__ */ new Set(),
277
+ allowedPlatforms: title.platforms
278
+ });
279
+ const terms = rows[0]?.terms ?? zeroAxisVector();
280
+ const topAxes = AXES.filter((axis) => terms[axis] > 0).sort((left, right) => terms[right] - terms[left]).slice(0, TOP_AXIS_COUNT);
281
+ return {
282
+ topAxes,
283
+ nearestOwnedId: nearestOwnedId(title.axes, owned),
284
+ gapPlatform: title.platforms.length === 1 ? title.platforms[0] ?? null : null
285
+ };
286
+ }
287
+
288
+ exports.AXES = AXES;
289
+ exports.GENRE_AXES = GENRE_AXES;
290
+ exports.GENRE_AXIS_COUNT = GENRE_AXIS_COUNT;
291
+ exports.axisConfidence = axisConfidence;
292
+ exports.buildTasteVector = buildTasteVector;
293
+ exports.clusterOf = clusterOf;
294
+ exports.cosine = cosine;
295
+ exports.diversify = diversify;
296
+ exports.dot = dot;
297
+ exports.explain = explain;
298
+ exports.l2Norm = l2Norm;
299
+ exports.scoreCatalog = scoreCatalog;
300
+ exports.selectQuizCards = selectQuizCards;
301
+ exports.titleWeight = titleWeight;
302
+ exports.zeroAxisVector = zeroAxisVector;
303
+ //# sourceMappingURL=index.js.map
304
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,288 @@
1
+ // src/axes.ts
2
+ var AXES = [
3
+ // genre (18)
4
+ "shooter",
5
+ "action",
6
+ "rpg",
7
+ "jrpg",
8
+ "strategy",
9
+ "simulation",
10
+ "racing",
11
+ "sports",
12
+ "platformer",
13
+ "puzzle",
14
+ "horror",
15
+ "adventure",
16
+ "roguelike",
17
+ "fighting",
18
+ "metroidvania",
19
+ "soulslike",
20
+ "mmo",
21
+ "survival",
22
+ // structure and feel (8)
23
+ "sessionLength",
24
+ "difficulty",
25
+ "narrative",
26
+ "coop",
27
+ "competitive",
28
+ "relaxing",
29
+ "replayability",
30
+ "artForward"
31
+ ];
32
+ var GENRE_AXIS_COUNT = 18;
33
+ var GENRE_AXES = AXES.slice(0, GENRE_AXIS_COUNT);
34
+ function zeroAxisVector() {
35
+ const out = {};
36
+ for (const axis of AXES) {
37
+ out[axis] = 0;
38
+ }
39
+ return out;
40
+ }
41
+ function l2Norm(vector) {
42
+ let sum = 0;
43
+ for (const axis of AXES) {
44
+ sum += vector[axis] * vector[axis];
45
+ }
46
+ return Math.sqrt(sum);
47
+ }
48
+ function dot(left, right) {
49
+ let sum = 0;
50
+ for (const axis of AXES) {
51
+ sum += left[axis] * right[axis];
52
+ }
53
+ return sum;
54
+ }
55
+ function cosine(left, right) {
56
+ const denominator = l2Norm(left) * l2Norm(right);
57
+ if (denominator === 0) {
58
+ return 0;
59
+ }
60
+ return dot(left, right) / denominator;
61
+ }
62
+
63
+ // src/buildTasteVector.ts
64
+ var RECENCY_MULTIPLIER = 2;
65
+ var OWNERSHIP_PLAYED = 1;
66
+ var OWNERSHIP_UNPLAYED = 0.15;
67
+ var PLAYED_HOURS_THRESHOLD = 0.5;
68
+ var UNPLAYED_BASE = 1;
69
+ var VERDICT_MULTIPLIER = {
70
+ loved: 2,
71
+ liked: 1.25,
72
+ bounced: 0.25,
73
+ never: 0
74
+ };
75
+ function titleWeight(title) {
76
+ const played = title.hours > PLAYED_HOURS_THRESHOLD;
77
+ const base = played ? Math.log1p(title.hours) : UNPLAYED_BASE;
78
+ const recency = title.playedRecently ? RECENCY_MULTIPLIER : 1;
79
+ const ownership = played ? OWNERSHIP_PLAYED : OWNERSHIP_UNPLAYED;
80
+ return base * recency * ownership;
81
+ }
82
+ function buildTasteVector(owned, answers) {
83
+ const verdicts = /* @__PURE__ */ new Map();
84
+ for (const answer of answers ?? []) {
85
+ verdicts.set(answer.titleId, VERDICT_MULTIPLIER[answer.verdict]);
86
+ }
87
+ const accumulator = zeroAxisVector();
88
+ for (const title of owned) {
89
+ const weight = titleWeight(title) * (verdicts.get(title.id) ?? 1);
90
+ if (weight === 0) {
91
+ continue;
92
+ }
93
+ for (const axis of AXES) {
94
+ accumulator[axis] += weight * title.axes[axis];
95
+ }
96
+ }
97
+ const norm = l2Norm(accumulator);
98
+ if (norm === 0) {
99
+ return accumulator;
100
+ }
101
+ for (const axis of AXES) {
102
+ accumulator[axis] /= norm;
103
+ }
104
+ return accumulator;
105
+ }
106
+
107
+ // src/confidence.ts
108
+ var CONFIDENCE_SCALE = 4;
109
+ function axisConfidence(owned) {
110
+ const evidence = zeroAxisVector();
111
+ for (const title of owned) {
112
+ const weight = titleWeight(title);
113
+ for (const axis of AXES) {
114
+ evidence[axis] += weight * title.axes[axis];
115
+ }
116
+ }
117
+ const confidence = zeroAxisVector();
118
+ for (const axis of AXES) {
119
+ confidence[axis] = 1 - Math.exp(-evidence[axis] / CONFIDENCE_SCALE);
120
+ }
121
+ return confidence;
122
+ }
123
+
124
+ // src/selectQuizCards.ts
125
+ var NO_OWNED_IDS = /* @__PURE__ */ new Set();
126
+ function informationGain(title, confidence) {
127
+ let gain = 0;
128
+ for (const axis of AXES) {
129
+ gain += (1 - confidence[axis]) * title.axes[axis];
130
+ }
131
+ return gain;
132
+ }
133
+ function selectQuizCards(catalog, conf, count, ownedIds = NO_OWNED_IDS) {
134
+ if (count <= 0) {
135
+ return [];
136
+ }
137
+ return catalog.filter((title) => !ownedIds.has(title.id)).map((title) => ({ id: title.id, gain: informationGain(title, conf) })).sort((left, right) => right.gain - left.gain).slice(0, count).map((candidate) => candidate.id);
138
+ }
139
+
140
+ // src/score.ts
141
+ var NEUTRAL_QUALITY = 0.8;
142
+ var QUALITY_FLOOR = 0.6;
143
+ var QUALITY_RANGE = 0.4;
144
+ var CRITIC_SCORE_MAX = 100;
145
+ var NOVELTY_STRENGTH = 0.2;
146
+ var MOOD_STRENGTH = 0.5;
147
+ function moodWeights(mood) {
148
+ if (mood === void 0) {
149
+ return [];
150
+ }
151
+ const weights = [];
152
+ for (const axis of AXES) {
153
+ const weight = mood[axis];
154
+ if (weight !== void 0 && weight > 0) {
155
+ weights.push({ axis, weight });
156
+ }
157
+ }
158
+ return weights;
159
+ }
160
+ function qualityTerm(criticScore) {
161
+ if (criticScore === null) {
162
+ return NEUTRAL_QUALITY;
163
+ }
164
+ const clamped = Math.min(Math.max(criticScore, 0), CRITIC_SCORE_MAX);
165
+ return QUALITY_FLOOR + QUALITY_RANGE * (clamped / CRITIC_SCORE_MAX);
166
+ }
167
+ function noveltyTerm(axes) {
168
+ let sum = 0;
169
+ let peak = 0;
170
+ for (const axis of AXES) {
171
+ sum += axes[axis];
172
+ peak = Math.max(peak, axes[axis]);
173
+ }
174
+ if (sum === 0) {
175
+ return 1;
176
+ }
177
+ return 1 - NOVELTY_STRENGTH * (peak / sum);
178
+ }
179
+ function moodTerm(axes, mood) {
180
+ if (mood.length === 0) {
181
+ return 1;
182
+ }
183
+ let weighted = 0;
184
+ let total = 0;
185
+ for (const entry of mood) {
186
+ weighted += entry.weight * axes[entry.axis];
187
+ total += entry.weight;
188
+ }
189
+ return 1 + MOOD_STRENGTH * (weighted / total);
190
+ }
191
+ function zeroRow(id) {
192
+ return { id, score: 0, terms: zeroAxisVector() };
193
+ }
194
+ function scoreOne(context, title) {
195
+ if (context.ownedIds.has(title.id)) {
196
+ return zeroRow(title.id);
197
+ }
198
+ if (!title.platforms.some((platform) => context.allowed.has(platform))) {
199
+ return zeroRow(title.id);
200
+ }
201
+ const denominator = context.tasteNorm * l2Norm(title.axes);
202
+ if (denominator === 0) {
203
+ return zeroRow(title.id);
204
+ }
205
+ const gain = noveltyTerm(title.axes) * qualityTerm(title.criticScore) * moodTerm(title.axes, context.mood);
206
+ const terms = zeroAxisVector();
207
+ let score = 0;
208
+ for (const axis of AXES) {
209
+ const term = context.taste[axis] * title.axes[axis] / denominator * gain;
210
+ terms[axis] = term;
211
+ score += term;
212
+ }
213
+ return { id: title.id, score, terms };
214
+ }
215
+ function scoreCatalog(taste, catalog, opts) {
216
+ const context = {
217
+ taste,
218
+ tasteNorm: l2Norm(taste),
219
+ ownedIds: opts.ownedIds,
220
+ allowed: new Set(opts.allowedPlatforms),
221
+ mood: moodWeights(opts.mood)
222
+ };
223
+ const rows = catalog.map((title) => scoreOne(context, title));
224
+ rows.sort((left, right) => right.score - left.score);
225
+ return rows;
226
+ }
227
+
228
+ // src/diversify.ts
229
+ function clusterOf(terms) {
230
+ let best = AXES[0];
231
+ let bestValue = -Infinity;
232
+ for (const axis of GENRE_AXES) {
233
+ if (terms[axis] > bestValue) {
234
+ bestValue = terms[axis];
235
+ best = axis;
236
+ }
237
+ }
238
+ return best;
239
+ }
240
+ function diversify(scored, limit, maxPerCluster) {
241
+ const perCluster = /* @__PURE__ */ new Map();
242
+ const out = [];
243
+ for (const row of scored) {
244
+ if (out.length >= limit) {
245
+ break;
246
+ }
247
+ const cluster = clusterOf(row.terms);
248
+ const taken = perCluster.get(cluster) ?? 0;
249
+ if (taken >= maxPerCluster) {
250
+ continue;
251
+ }
252
+ perCluster.set(cluster, taken + 1);
253
+ out.push(row);
254
+ }
255
+ return out;
256
+ }
257
+
258
+ // src/explain.ts
259
+ var TOP_AXIS_COUNT = 3;
260
+ function nearestOwnedId(axes, owned) {
261
+ let best = null;
262
+ let bestSimilarity = -Infinity;
263
+ for (const title of owned) {
264
+ const similarity = cosine(axes, title.axes);
265
+ if (similarity > bestSimilarity) {
266
+ bestSimilarity = similarity;
267
+ best = title.id;
268
+ }
269
+ }
270
+ return best;
271
+ }
272
+ function explain(taste, title, owned) {
273
+ const rows = scoreCatalog(taste, [title], {
274
+ ownedIds: /* @__PURE__ */ new Set(),
275
+ allowedPlatforms: title.platforms
276
+ });
277
+ const terms = rows[0]?.terms ?? zeroAxisVector();
278
+ const topAxes = AXES.filter((axis) => terms[axis] > 0).sort((left, right) => terms[right] - terms[left]).slice(0, TOP_AXIS_COUNT);
279
+ return {
280
+ topAxes,
281
+ nearestOwnedId: nearestOwnedId(title.axes, owned),
282
+ gapPlatform: title.platforms.length === 1 ? title.platforms[0] ?? null : null
283
+ };
284
+ }
285
+
286
+ export { AXES, GENRE_AXES, GENRE_AXIS_COUNT, axisConfidence, buildTasteVector, clusterOf, cosine, diversify, dot, explain, l2Norm, scoreCatalog, selectQuizCards, titleWeight, zeroAxisVector };
287
+ //# sourceMappingURL=index.mjs.map
288
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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"]}
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@dloizides/taste-engine",
3
+ "version": "0.1.0",
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
+ "keywords": [
6
+ "recommender",
7
+ "taste-vector",
8
+ "cosine-similarity",
9
+ "scoring",
10
+ "diversify",
11
+ "games",
12
+ "dloizides"
13
+ ],
14
+ "author": "dloizides",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/openmindednewby/taste-engine.git"
19
+ },
20
+ "homepage": "https://github.com/openmindednewby/taste-engine#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/openmindednewby/taste-engine/issues"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "module": "./dist/index.mjs",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "browser": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "require": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "import": {
38
+ "types": "./dist/index.d.mts",
39
+ "default": "./dist/index.mjs"
40
+ },
41
+ "default": {
42
+ "types": "./dist/index.d.ts",
43
+ "default": "./dist/index.js"
44
+ }
45
+ }
46
+ },
47
+ "files": [
48
+ "dist",
49
+ "README.md",
50
+ "CHANGELOG.md"
51
+ ],
52
+ "sideEffects": false,
53
+ "engines": {
54
+ "node": ">=18.0.0"
55
+ },
56
+ "scripts": {
57
+ "build": "rimraf dist && tsup",
58
+ "build:watch": "tsup --watch",
59
+ "test": "jest",
60
+ "test:watch": "jest --watch",
61
+ "test:coverage": "jest --coverage",
62
+ "lint": "eslint src --ext .ts",
63
+ "lint:fix": "eslint src --ext .ts --fix",
64
+ "typecheck": "tsc --noEmit",
65
+ "clean": "rimraf dist",
66
+ "security:audit": "npm audit --audit-level=high",
67
+ "prepublishOnly": "npm run clean && npm run build && npm run test"
68
+ },
69
+ "devDependencies": {
70
+ "@types/jest": "^29.5.0",
71
+ "@types/node": "^20.19.32",
72
+ "@typescript-eslint/eslint-plugin": "^7.0.0",
73
+ "@typescript-eslint/parser": "^7.0.0",
74
+ "eslint": "^8.57.0",
75
+ "jest": "^29.7.0",
76
+ "rimraf": "^5.0.0",
77
+ "ts-jest": "^29.1.0",
78
+ "tsup": "^8.0.0",
79
+ "typescript": "^5.4.0"
80
+ }
81
+ }