@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 +34 -18
- package/README.md +91 -87
- package/dist/index.d.mts +17 -4
- package/dist/index.d.ts +17 -4
- package/dist/index.js +32 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +32 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
- `
|
|
13
|
-
|
|
14
|
-
- `
|
|
15
|
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
- `
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- `
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
+
// 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`
|
|
104
|
-
*
|
|
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`
|
|
104
|
-
*
|
|
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
|
|
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
|
|
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 -
|
|
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
|
|
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
|
|
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
|
|
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 -
|
|
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
|
|
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);
|
package/dist/index.mjs.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":";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.
|
|
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",
|