npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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 artForward

API

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): Explanation

How 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.

  • platformGate is 0 or 1. An owned id and a title on no allowed platform both score exactly 0, not merely a low number.
  • quality maps a critic score onto 0.6..1.0; a null critic score is 0.8. Absence of a score is neutral, never a penalty - most console back-catalogue has no score at all.
  • novelty discounts a title whose axis mass sits on one axis.
  • mood lifts by up to 50% for a title matching the requested mood axes.
  • moodFactor and qualityFactor are exported, and they are the same functions scoreCatalog uses, 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-engine

MIT.