Biased Matrix Factorization recommendation engine, packaged as a Cloudflare Worker and a standalone npm library.
- Ingests anonymous interaction events (
read,upvote,downvote,save,seen) from any client viaPOST /interactions - Learns per-user and per-item latent factors online via BiasedMF SGD
- Returns personalised ranked article-ID lists via
GET /recommendations/:userId, with the requesting user's downvoted articles excluded - Exports pure scoring functions as an npm library for use in any JS/TS project
| Method | Path | Body / Params | Response |
|---|---|---|---|
GET |
/health |
— | 200 OK |
POST |
/interactions |
{ events: InteractionEvent[] } or bare InteractionEvent[] (max 200 per batch) |
200 or 400 |
GET |
/recommendations/:userId |
Optional limit, candidates=id1,id2,... |
RecResponse (JSON) |
POST |
/recommendations/:userId |
RecRankRequest |
RecResponse (JSON) |
- Deduplication: same
(userId, articleId, action)triple is stored once — safe to retry. - Downvote exclusion:
articleIdsnever contains articles the user has downvoted, regardless of global popularity. - No PII:
userIdmust be an anonymous stable hash. No email, name, or device identifier. - Batch cap:
POST /interactionsrejects arrays > 200 events with400. - Cache: recommendations are KV-cached for a short TTL; expect up to ~60 s staleness after new interactions.
type Topic = 'technology' | 'science' | 'world' | 'business' |
'health' | 'environment' | 'sports' | 'entertainment' | 'general';
type Action = 'read' | 'upvote' | 'downvote' | 'save' | 'seen';
interface InteractionEvent {
userId: string; // anonymous stable ID (e.g. SHA-256 of IndexedDB deviceId)
articleId: string; // 16-hex ID — SHA-256(url)[:8] from rss-worker
sourceId: string; // feed slug, e.g. "ars-technica"
topics: Topic[]; // 1–3 topics
action: Action;
ts: number; // epoch ms (advisory — server uses its own clock to prevent prune-window spoofing)
}
interface RecResponse {
articleIds: string[]; // ranked by personalised score, downvoted articles excluded
generatedAt: number; // epoch ms
scoredArticleIds: Array<{ articleId: string; score: number }>;
diagnostics: {
model: 'biased-mf';
modelVersion: string;
factorCount: number;
candidateMode?: 'feed-pool' | 'global';
candidateStrategy?: 'diverse' | 'top-bias' | 'feed-pool';
candidateCount: number;
rankedCount: number;
returnedCount: number;
excludedDownvotes: number;
coldItemCount?: number;
warmItemCount?: number;
coldStart: boolean;
limit: number;
};
trace: {
requestId: string;
cfRay?: string;
};
cache: {
status: 'hit' | 'miss' | 'bypass';
key: string;
ttlSec: number;
ageSec: number;
};
timingMs: {
total: number;
cacheLookup: number;
doFetch: number;
cacheWrite: number;
};
}interface RecRankRequest {
candidateArticleIds?: string[]; // when present, rank only this caller feed-pool (max 100)
topicWeights?: Record<string, number>; // optional per-topic score multipliers
limit?: number; // default 50, max 200
}/recommendations/:userId always returns observability fields alongside ranked IDs.
When candidates are provided (POST body or GET ?candidates=), ranking is pool-scoped.
{
"articleIds": ["a3f1c2d4b5e60718", "b4e2d3c4a5f60719"],
"generatedAt": 1778855365123,
"scoredArticleIds": [
{ "articleId": "a3f1c2d4b5e60718", "score": 1.7421 },
{ "articleId": "b4e2d3c4a5f60719", "score": 1.3396 }
],
"diagnostics": {
"model": "biased-mf",
"modelVersion": "v1",
"factorCount": 10,
"candidateMode": "global",
"candidateStrategy": "top-bias",
"candidateCount": 200,
"rankedCount": 187,
"returnedCount": 50,
"excludedDownvotes": 13,
"coldItemCount": 2,
"warmItemCount": 185,
"coldStart": false,
"limit": 50
},
"trace": {
"requestId": "2f4b1e51-1835-4561-95eb-40dc8bd4ddcd",
"cfRay": "8c2a6d0f4b8a1234-IAD"
},
"cache": {
"status": "hit",
"key": "recs:user0000000000001",
"ttlSec": 300,
"ageSec": 42
},
"timingMs": {
"total": 7,
"cacheLookup": 2,
"doFetch": 0,
"cacheWrite": 0
}
}| Action | Rating |
|---|---|
save |
2.0 |
upvote |
1.0 |
read |
0.5 |
seen |
0.1 |
downvote |
−1.0 |
// 1. Fire-and-forget: send interaction when user acts
await fetch('https://rec-worker.example.com/interactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ events: [{
userId, articleId, sourceId, topics, action: 'upvote', ts: Date.now(),
}] }),
});
// 2. Fetch ranked IDs; intersect with locally available articles
const { articleIds } = await fetch(
`https://rec-worker.example.com/recommendations/${userId}`
).then(r => r.json()) as RecResponse;
const ranked = articleIds
.map(id => localArticleMap.get(id))
.filter(Boolean);import {
mfPredict, mfLearnOne, ACTION_RATING, DEFAULT_MF_PARAMS,
newFactorRow, zeroFactorRow, isValidEvent,
type InteractionEvent, type RecResponse, type MfParams, type FactorRow, type MfLearnInput,
} from '@victusfate/ricochet';Install:
# npm registry
npm install @victusfate/ricochet
# directly from GitHub
npm install github:victusfate/ricochetNo Cloudflare dependencies — safe to import in Node, browsers, and other edge runtimes.
The Worker entry and Durable Object are also exported so you can wrap them with your own wrangler.jsonc:
// your-worker/src/index.ts
export { default, RecDO } from '@victusfate/ricochet/worker';Required binding names — these must match exactly or the worker will fail to start:
| Binding | Type | Name |
|---|---|---|
| KV namespace | KVNamespace |
REC_STORE |
| Durable Object | DurableObjectNamespace |
REC_DO |
| Optional env var | string |
EXTRA_CORS_ORIGINS |
The EXTRA_CORS_ORIGINS env var accepts a comma-separated list of additional allowed CORS origins (e.g. a custom Cloudflare Pages domain).
Production domains are expected to be configured through EXTRA_CORS_ORIGINS by the integrating app/deployment (for example Boomerang platform-worker). Ricochet keeps only localhost defaults in code for dev ergonomics.
make install # npm install
make test # vitest suite
make build # compile library to dist/
make eval # download MovieLens 100K + run offline BiasedMF evaluation
make dev # wrangler dev on :8790 (alias for npm run dev)Or with npm directly:
npm install
npm test
npm run build
npm run dev
npm run deployVersion bumps are automated from conventional commits on main via semantic-release.
fix:commits trigger patch releasesfeat:commits trigger minor releasesBREAKING CHANGE:in commit body/footer (or!) triggers major releases
To preview the next computed version locally:
npm run release:dry-runmake data # download MovieLens 100K into data/ml-100k/
make eval # run evaluation — RMSE, MAE, filter verificationResults on MovieLens 100K (100k ratings, 943 users, 1682 items, 80/20 split):
| Predictor | RMSE | MAE |
|---|---|---|
| Global mean | 1.122 | 0.941 |
| Item mean | 1.017 | 0.811 |
| BiasedMF | 0.930 | 0.733 |
The six positional arguments have been replaced with a single MfLearnInput object.
// before (v1.x)
const res = mfLearnOne(params, globalMean, n, user, item, rating);
// after (v2.0)
const res = mfLearnOne({ params, globalMean, n, user, item, rating });MfLearnInput is exported from the package for typed construction:
import type { MfLearnInput } from '@victusfate/ricochet';Return value is unchanged: { globalMean, n, user, item }.
Three breaking changes require action depending on how you use ricochet.
The broad *.pages.dev wildcard was removed from the CORS allow-list. Any
Cloudflare Pages origin that previously worked without configuration will now
receive a CORS error.
Action: add your Pages domain to EXTRA_CORS_ORIGINS in your wrangler.jsonc
(or environment secret):
// wrangler.jsonc
{
"vars": {
"EXTRA_CORS_ORIGINS": "https://your-project.pages.dev"
}
}Multiple origins are comma-separated:
"https://your-project.pages.dev,https://custom.example.com"
If you pass a custom MfParams object you must rename the field:
// before
const params: MfParams = { ...DEFAULT_MF_PARAMS, clipGradient: 5.0 };
// after
const params: MfParams = { ...DEFAULT_MF_PARAMS, clipError: 5.0 };DEFAULT_MF_PARAMS is updated automatically — no change needed if you use it
as-is.
3. Removed exports: RankingCacheEntry, REC_FEED_POOL_CACHE_TTL_MS, REC_GLOBAL_CACHE_TTL_MS, ArticleScore (npm library)
These types and constants were exported since v1.3 but were unused internally and not part of the core API. They have been removed.
Action: if you imported any of them, either inline the definitions in your own codebase or open an issue if you have a concrete use-case for them.
// before (no longer exported)
import { RankingCacheEntry, REC_FEED_POOL_CACHE_TTL_MS, ArticleScore } from '@victusfate/ricochet';
// after — define locally if needed, e.g.
const FEED_POOL_TTL_MS = 5 * 60 * 1000;| Change | Details |
|---|---|
POST /interactions bare-array body |
Both { events: [...] } and a bare InteractionEvent[] are now accepted |
limit max corrected |
Always enforced at 200; docs previously claimed 500 |
diagnostics.candidateStrategy |
New field: 'diverse' | 'top-bias' | 'feed-pool' indicating which candidate pool strategy ran |
diagnostics.coldStart |
Meaning unchanged: true when the user has no factor row yet |
POST /recommendations no longer returns 304 |
Was a spec violation; POSTs now always return a full body |
Design, PRD, implementation plan, and TDD log live in docs/biased-mf-recs/.
Auto-generated TypeDoc reference lives in docs/api/, committed directly to the repo.
Regenerate locally with npm run docs:api (output → docs/api/). On every push to main that touches src/ or typedoc.json, .github/workflows/docs.yml regenerates the Markdown and commits it back automatically.