openvibers/openvibe.deals Commits
The hotness tick takes only offers whose hot can change: 40,128 to 0 today (#203)
Changes
@@@ -15,8 +15,10 @@** The same inputs always give the same number (pure function, fixed rounding). Every snapshot* stores all of its inputs, so anyone can recompute it (GET /api/v1/offers/:id/hotness).-- * Snapshots are taken when votes change, on merge/unmerge, on creation, and by the worker for all-- * active offers at one shared `t` (so the hot list compares numbers computed at the same moment).++ * Snapshots are taken when votes change, on merge/unmerge, on creation, and by the worker for the++ * active offers whose hot is not 0, at one shared `t` (so the hot list compares numbers computed at the++ * same moment). An offer with no net vote has score 0 and so hot 0 at every age: a tick would only++ * write the same 0 again (5.5M rows a day once imports made 40,000 offers, 2026-11-02).*/const FORMULA = 'hot@1';const HOUR = 3600 * 1000;@@@ -69,9 +71,15 @@ function createHotness({ store, reads }) {return formula({ upWeight: snap.up_weight, downWeight: snap.down_weight, firstSeenAt: snap.first_seen_at, t: snap.computed_at });}-- /** Worker: snapshot every active canonical offer created in the window, all at the same t. */++ /**++ * Worker: snapshot the active canonical offers created in the window whose hot can change with time, all at the++ * same t. Hot is score / (age + 2)^1.5, so it moves with age only when the score is not 0; an offer whose latest++ * hot is 0 (no net vote) stays 0 until a vote, which takes its own snapshot. An offer with no snapshot yet is++ * included, so it gets one.++ */async function tick({ windowDays = 14, t = store.now() } = {}) {-- const ids = (await db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);++ const ids = (await db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?++ AND (hot_latest IS NULL OR hot_latest <> 0)`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });// Keep the latest snapshot per offer, every snapshot a vote, merge or creation made in the last 7 days, and the// worker's own tick snapshots of the last day. A tick re-snapshots every active offer (about 2,700, every ten@@@ -99,6 +99,22 @@ const { formula } = require('../server/domain/hotness');assert.deepStrictEqual(hots, [...hots].sort((a, b) => b - a));});++ await check('the tick skips offers whose hot is 0 (no net vote: 0 at every age); a vote brings one back', async () => {++ const db = t.ctx.store.db;++ const Q = await t.submit(poster, { url: 'https://a.example/quiet', title: 'Offer Quiet', price: '7', currency: 'USD' });++ assert.strictEqual(Number((await db.prepare('SELECT hot_latest FROM deal_offers WHERE id = ?').get(Q.id)).hot_latest), 0, 'created with hot 0');++ const ticked = async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM deal_hotness_snapshots WHERE offer_id = ? AND reason = 'tick'").get(Q.id)).n);++ t.clock.advance(HOUR);++ await t.ctx.worker.tick();++ assert.strictEqual(await ticked(), 0, 'a tick writes no new 0 for it');++ await t.get(`/d/${Q.slug}/vote`, { as: users[0], form: { value: 'up' } });++ t.clock.advance(HOUR);++ await t.ctx.worker.tick();++ assert.strictEqual(await ticked(), 1, 'with a vote its hot moves with age, so the tick takes it');++ const last = await t.ctx.reads.lastSnapshot(Q.id);++ assert.ok(last.hot > 0 && last.reason === 'tick');++ });++await check('tick snapshots are kept a day, the others 7 days, and every offer\'s latest always', async () => {const db = t.ctx.store.db;const Y = await t.submit(poster, { url: 'https://a.example/keep', title: 'Offer Keep', price: '5', currency: 'USD' });
@@ -15,8 +15,10 @@
@@ -15,8 +15,10 @@
*
*
* The same inputs always give the same number (pure function, fixed rounding). Every snapshot
* The same inputs always give the same number (pure function, fixed rounding). Every snapshot
* stores all of its inputs, so anyone can recompute it (GET /api/v1/offers/:id/hotness).
* stores all of its inputs, so anyone can recompute it (GET /api/v1/offers/:id/hotness).
- * Snapshots are taken when votes change, on merge/unmerge, on creation, and by the worker for all
- * active offers at one shared `t` (so the hot list compares numbers computed at the same moment).
+ * Snapshots are taken when votes change, on merge/unmerge, on creation, and by the worker for the
+ * active offers whose hot is not 0, at one shared `t` (so the hot list compares numbers computed at the
+ * same moment). An offer with no net vote has score 0 and so hot 0 at every age: a tick would only
+ * write the same 0 again (5.5M rows a day once imports made 40,000 offers, 2026-11-02).
*/
*/
const FORMULA = 'hot@1';
const FORMULA = 'hot@1';
const HOUR = 3600 * 1000;
const HOUR = 3600 * 1000;
@@ -69,9 +71,15 @@ function createHotness({ store, reads }) {
@@ -69,9 +71,15 @@ function createHotness({ store, reads }) {
return formula({ upWeight: snap.up_weight, downWeight: snap.down_weight, firstSeenAt: snap.first_seen_at, t: snap.computed_at });
return formula({ upWeight: snap.up_weight, downWeight: snap.down_weight, firstSeenAt: snap.first_seen_at, t: snap.computed_at });
}
}
- /** Worker: snapshot every active canonical offer created in the window, all at the same t. */
+ /**
+ * Worker: snapshot the active canonical offers created in the window whose hot can change with time, all at the
+ * same t. Hot is score / (age + 2)^1.5, so it moves with age only when the score is not 0; an offer whose latest
+ * hot is 0 (no net vote) stays 0 until a vote, which takes its own snapshot. An offer with no snapshot yet is
+ * included, so it gets one.
+ */
async function tick({ windowDays = 14, t = store.now() } = {}) {
async function tick({ windowDays = 14, t = store.now() } = {}) {
- const ids = (await db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);
+ const ids = (await db.prepare(`SELECT id FROM deal_offers WHERE status = 'active' AND merged_into IS NULL AND created_at >= ?
+ AND (hot_latest IS NULL OR hot_latest <> 0)`).all(t - windowDays * 24 * HOUR)).map((r) => r.id);
await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });
await store.tx(async () => { for (const id of ids) await snapshot(id, 'tick', t); });
// Keep the latest snapshot per offer, every snapshot a vote, merge or creation made in the last 7 days, and the
// Keep the latest snapshot per offer, every snapshot a vote, merge or creation made in the last 7 days, and the
// worker's own tick snapshots of the last day. A tick re-snapshots every active offer (about 2,700, every ten
// worker's own tick snapshots of the last day. A tick re-snapshots every active offer (about 2,700, every ten
@@ -99,6 +99,22 @@ const { formula } = require('../server/domain/hotness');
@@ -99,6 +99,22 @@ const { formula } = require('../server/domain/hotness');
assert.deepStrictEqual(hots, [...hots].sort((a, b) => b - a));
assert.deepStrictEqual(hots, [...hots].sort((a, b) => b - a));
});
});
+ await check('the tick skips offers whose hot is 0 (no net vote: 0 at every age); a vote brings one back', async () => {
+ const db = t.ctx.store.db;
+ const Q = await t.submit(poster, { url: 'https://a.example/quiet', title: 'Offer Quiet', price: '7', currency: 'USD' });
+ assert.strictEqual(Number((await db.prepare('SELECT hot_latest FROM deal_offers WHERE id = ?').get(Q.id)).hot_latest), 0, 'created with hot 0');
+ const ticked = async () => Number((await db.prepare("SELECT COUNT(*) AS n FROM deal_hotness_snapshots WHERE offer_id = ? AND reason = 'tick'").get(Q.id)).n);
+ t.clock.advance(HOUR);
+ await t.ctx.worker.tick();
+ assert.strictEqual(await ticked(), 0, 'a tick writes no new 0 for it');
+ await t.get(`/d/${Q.slug}/vote`, { as: users[0], form: { value: 'up' } });
+ t.clock.advance(HOUR);
+ await t.ctx.worker.tick();
+ assert.strictEqual(await ticked(), 1, 'with a vote its hot moves with age, so the tick takes it');
+ const last = await t.ctx.reads.lastSnapshot(Q.id);
+ assert.ok(last.hot > 0 && last.reason === 'tick');
+ });
+
await check('tick snapshots are kept a day, the others 7 days, and every offer\'s latest always', async () => {
await check('tick snapshots are kept a day, the others 7 days, and every offer\'s latest always', async () => {
const db = t.ctx.store.db;
const db = t.ctx.store.db;
const Y = await t.submit(poster, { url: 'https://a.example/keep', title: 'Offer Keep', price: '5', currency: 'USD' });
const Y = await t.submit(poster, { url: 'https://a.example/keep', title: 'Offer Keep', price: '5', currency: 'USD' });