@dloizides/taste-engine
v0.5.0
Published
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 re
Maintainers
Readme
@dloizides/taste-engine
Weighted-vector taste modelling over a fixed 26-axis space. Pure TypeScript: no I/O, no React, no network, no clock. Everything runs in the browser against a catalogue the caller already has in memory.
Built for NextGame (recommend console games from a Steam library), but nothing in it knows what a game is - it is 26 named axes, a library of weighted items, and a catalogue to rank.
The axes
18 genre axes, then 8 structure-and-feel axes. Order is part of the contract: diversify
clusters on the first 18.
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 artForwardAPI
buildTasteVector(owned: OwnedTitle[], answers?: QuizAnswer[]): AxisVector
axisConfidence(owned: OwnedTitle[]): Record<AxisKey, number>
selectQuizCards(catalog, conf, count, ownedIds?): string[]
scoreCatalog(taste, catalog, opts: ScoreOptions): ScoredTitle[]
moodFactor(axes: AxisVector, mood?: Partial<AxisVector>): number // the mood multiplier scoreCatalog applies
qualityFactor(criticScore: number | null): number // the quality multiplier scoreCatalog applies
// ScoreOptions = { ownedIds; allowedPlatforms; mood?; ownedTitles? }
diversify(scored, limit, maxPerCluster): ScoredTitle[]
explain(taste, title, owned): ExplanationHow the numbers are chosen
buildTasteVector - per-title weight is log1p(hours) * recency * ownership, where
recency is 2.0 for a title played recently and 1.0 otherwise, and ownership is 1.0
above 0.5 hours and 0.15 below it. Logarithmic hours stop a single 900-hour habit from
flattening the rest of the library: 900h carries log1p(900) = 6.80 against 40h at 3.71,
not 22 times as much. For an unplayed title log1p(0) is 0 and would erase it entirely, so
the log term is replaced by 1 and only the 0.15 ownership weight survives - an unbought
opinion, not a played one. The result is L2-normalised, so downstream cosine is a pure
direction comparison.
A QuizAnswer modulates the weight of the owned title it names: loved x2, liked x1.25,
bounced x0.25, never x0.
The answers are ALSO evidence in their own right, whether or not the player owns the game each one
names - a quiz worth asking asks about titles outside the library. Each answer's optional axes
(the rated title's own axes) is added with a signed weight - loved +2, liked +1.25, bounced
-1, never 0 - and any axis the subtraction drives below zero is floored at 0, so the vector stays
in the 0..1 contract. Answers without axes, only-never answers and no answers at all carry
nothing.
The two sides are each L2-normalised BEFORE they are mixed, then combined as
(1 - s) * libraryUnit + s * answerUnit and renormalised. Normalising first is the whole trick: a
200-title library and 8 answers both become unit vectors, so library SIZE cannot drown the quiz -
only direction competes. The answer share saturates, s = 0.5 * n / (n + 4) over the n answers
that carry usable evidence: 1 -> 0.100, 4 -> 0.250, 8 -> 0.333, 20 -> 0.417, limit 0.5. One answer
is weak evidence, twenty is not twenty times stronger, and the library is never outvoted.
Both ends of that formula are exact, not approximate. An empty library is s = 1 - the guest
vector, answers only. A library with no usable answers is s = 0 - the library vector, unchanged,
so a player who skips the quiz ranks exactly as they did before the blend existed.
axisConfidence - 1 - exp(-evidence / 4) per axis. Zero evidence gives exactly zero
confidence, which is what selectQuizCards hunts for.
selectQuizCards - ranks candidates by how much of their axis mass lands on
low-confidence axes. ownedIds is a parameter rather than a pre-filter so a caller cannot
forget it and quiz someone on a game they already own.
scoreCatalog - cosine * platformGate * novelty * quality * mood.
platformGateis 0 or 1. An owned id and a title on no allowed platform both score exactly0, not merely a low number.qualitymaps a critic score onto0.6..1.0; anullcritic score is0.8. Absence of a score is neutral, never a penalty - most console back-catalogue has no score at all.noveltydiscounts a title whose axis mass sits on one axis.moodlifts by up to 50% for a title matching the requested mood axes.moodFactorandqualityFactorare exported, and they are the same functionsscoreCataloguses, so a caller ranking without a taste vector does not copy the maths.
terms is the per-axis decomposition of the product, so the terms sum to the score exactly,
and factors reports breadth, novelty, quality and mood separately rather than
multiplied into one opaque number. That is what makes explain honest.
explain - runs the title back through scoreCatalog and sorts the real terms. It does
not recompute a lookalike heuristic, so an explanation cannot disagree with the ranking it
explains. gapPlatform is a property of the TITLE, not of the user: it is platforms[0] when a title
exists on exactly one platform, and null otherwise. OwnedTitle carries no platform, so the
engine cannot and does not know which platforms a user has. It marks a single-platform
exclusive; deciding whether that platform is a gap FOR THIS USER belongs to the caller.
Performance
score.perf.test.ts scores 2,100 synthetic titles and fails above 100ms. The claim is a
test, not a hope.
NOT COVERED by the test suite
The tests prove the arithmetic. They say nothing about whether the axis values assigned to
any given title are right. A title tagged difficulty: 0.9 that is actually easy passes
every check here. Catalogue accuracy is a human spot-check, not a unit test.
Install
npm install @dloizides/taste-engineMIT.
