/** * Section 10 — ranking semantics. * * The other ten spec files prove the deployment answers: TLS, auth, quorum * commit, convergence, isolation, dashboards, backups. Not one of them writes a * signal and observes an order change, so tidalDB's actual thesis — `VISION.md:17` * "Ranking is not a feature. It is a primitive." — was unverified. * * These five checks verify it against a throwaway standalone node this suite * boots and seeds itself (60 items, 4 categories, deterministic vectors from * `tidal-stress`'s own generator). Hermetic: no cluster, no credential, no * network. Run with `npm run test:e2e:semantics`. * * Two of the five report a product gap rather than a success, because that is * what the measurement said. They are written as tripwires in the honest * direction — the same shape as the inert-feature checks in * `09-operator-authority.spec.ts` — so the day the gap closes, the test fails * with an instruction instead of the gap persisting silently. */ import { expect, test, type APIRequestContext } from '@playwright/test'; import { recordJson } from '../support/evidence.ts'; import { PROBE_IDS } from '../app/fixture-contract.ts'; import { startApp, type RunningApp } from '../app/harness.ts'; /** One row of a ranked response (`tidal-server/src/dto.rs:269`). */ type FeedItem = { entity_id: number; score: number; rank: number; /** Absent, not `[]`, when a profile reports nothing (`dto.rs:373`). */ signals?: { name: string; value: number }[]; }; /** The whole catalog in one read, so "last" means globally worst, not page-worst. */ const FULL_CORPUS = 60; /** * `for_you`, never `trending`. Measured on this schema, `trending` reports * `view_velocity` and `share_velocity` — and `share` is not a declared signal * here, so that term is permanently 0. An unknown profile name is answered with * 500 rather than 400, so profile names are pinned to ones probed as live. */ const PROFILE = 'for_you'; async function readFeed( request: APIRequestContext, app: RunningApp, limit = FULL_CORPUS, ): Promise { const response = await request.get(`${app.url}/api/feed?profile=${PROFILE}&limit=${limit}`); expect(response.status(), 'the feed must answer before anything is concluded from it').toBe(200); const body = (await response.json()) as { items?: FeedItem[] }; const items = body.items ?? []; expect(items.length, 'an empty feed cannot support any ranking assertion').toBeGreaterThan(0); return items; } async function writeSignal( request: APIRequestContext, app: RunningApp, entityId: number, signal: string, ): Promise { const response = await request.post(`${app.url}/api/signals`, { data: { entity_id: entityId, signal, weight: 1.0 }, }); expect(response.status(), `POST /signals ${signal} on ${entityId} must be accepted`).toBe(204); } const positionOf = (items: FeedItem[], entityId: number): number => items.findIndex((item) => item.entity_id === entityId); const signalValue = (item: FeedItem | undefined, name: string): number | undefined => item?.signals?.find((signal) => signal.name === name)?.value; test.describe('section 10 — ranking semantics', () => { test('a like moves the item up, immediately — no ETL in between', async ({ request, }, testInfo) => { const app = await startApp(); try { const before = await readFeed(request, app); const target = before[before.length - 1]!.entity_id; const beforeIndex = positionOf(before, target); await writeSignal(request, app, target, 'like'); // No sleep and no retry between the write and the read. VISION.md:166 // claims the next query reflects the write with "no Kafka consumer to lag, // no feature store sync to schedule". Polling here would be testing our // patience rather than that claim. const after = await readFeed(request, app); const afterIndex = positionOf(after, target); await recordJson(testInfo, 'like-moves-up', { entityId: target, beforeIndex, afterIndex, likeBoost: signalValue(after[afterIndex], 'like_boost'), profile: PROFILE, }); // Position, not score. Scores are floats a profile owns and re-normalises // across the candidate set (measured: unliked items go 0.5 -> 0.0 once one // item is boosted). Position is the contract a user actually experiences. expect( afterIndex, `a like must improve position immediately; ${target} went ${beforeIndex} -> ${afterIndex}`, ).toBeLessThan(beforeIndex); expect(afterIndex, 'a single like on the worst item should reach the top').toBe(0); } finally { await app.close(); } }); test('a skip is durably accepted and then ignored by every built-in profile', async ({ request, }, testInfo) => { const app = await startApp(); try { // Every profile this deployment exposes and which resolves (`top` returns // 500 despite existing in engine source, so it is not probed). const profiles = [ 'for_you', 'trending', 'hot', 'new', 'shuffle', 'hidden_gems', 'controversial', ]; const baseline = await readFeed(request, app); const target = baseline[20]!.entity_id; const positionsBefore: Record = {}; for (const profile of profiles) { const response = await request.get( `${app.url}/api/feed?profile=${profile}&limit=${FULL_CORPUS}`, ); const body = (await response.json()) as { items?: FeedItem[] }; positionsBefore[profile] = positionOf(body.items ?? [], target); } // Five skips, not one: a single write could plausibly fall below a rounding // threshold. Five cannot. for (let n = 0; n < 5; n += 1) await writeSignal(request, app, target, 'skip'); const positionsAfter: Record = {}; const reported: Record = {}; for (const profile of profiles) { const response = await request.get( `${app.url}/api/feed?profile=${profile}&limit=${FULL_CORPUS}`, ); const body = (await response.json()) as { items?: FeedItem[] }; const items = body.items ?? []; positionsAfter[profile] = positionOf(items, target); reported[profile] = (items[positionsAfter[profile]]?.signals ?? []).map((s) => s.name); } await recordJson(testInfo, 'skip-is-inert', { entityId: target, skipsWritten: 5, declaredDecay: app.declaredDecay, positionsBefore, positionsAfter, signalNamesReported: reported, }); // The schema declares `skip` (permanent), and the write is accepted, so the // event is durably recorded. But `Penalty` — the mechanism that would let it // demote anything (`tidal/src/ranking/profile.rs:227`, applied at // `tidal/src/ranking/executor/signal_values.rs:183`, labelled `{signal}_penalty` // at `tidal/src/ranking/executor/mod.rs:65`) — is never populated: every // built-in profile is built from `skeleton()`, which sets // `penalties: vec![]` (`tidal/src/ranking/builtins.rs:62`), and no built-in // overrides it. So `VISION.md:187` "Negative signals are equal citizens" does // not hold for any shipped profile: a skip is stored and query-time inert. expect(app.declaredDecay.skip, 'the schema must still declare skip').toBe('permanent'); for (const profile of profiles) { expect( positionsAfter[profile], `${profile} moved ${target} after 5 skips (${positionsBefore[profile]} -> ` + `${positionsAfter[profile]}). Good news: a profile now applies a skip ` + `penalty. Rewrite this test as a demotion assertion and close the ` + `"negative signals are inert" finding.`, ).toBe(positionsBefore[profile]); expect( reported[profile], `${profile} now reports a skip term. Same good news as above.`, ).not.toContain('skip_penalty'); } } finally { await app.close(); } }); test('decay is applied continuously at query time, at each signal\u2019s declared half-life', async ({ request, }, testInfo) => { const app = await startApp(); try { const viewItem = 900_000_300; const likeItem = 900_000_301; // Written concurrently so both carry the same elapsed time, which makes the // ratio of their decay rates depend only on their declared half-lives. await Promise.all([ writeSignal(request, app, viewItem, 'view'), writeSignal(request, app, likeItem, 'like'), ]); const first = await readFeed(request, app); const readOne = Date.now(); // A real interval, deliberately. This is not a sleep to make an assertion // pass — elapsed time IS the independent variable of the property under // test. `CODING_GUIDELINES.md:88` says "decay is a type, not a formula you // call"; the observable consequence is that the SAME stored event reports a // smaller value on a later read, with no writes in between. await new Promise((resolve) => setTimeout(resolve, 4_000)); const second = await readFeed(request, app); const readTwo = Date.now(); const elapsedSeconds = (readTwo - readOne) / 1_000; const viewFirst = signalValue(first[positionOf(first, viewItem)], 'view_boost'); const viewSecond = signalValue(second[positionOf(second, viewItem)], 'view_boost'); const likeFirst = signalValue(first[positionOf(first, likeItem)], 'like_boost'); const likeSecond = signalValue(second[positionOf(second, likeItem)], 'like_boost'); // Guard the measurement before concluding anything from it: an absent value // would otherwise read as "no decay". for (const [label, value] of Object.entries({ viewFirst, viewSecond, likeFirst, likeSecond })) { expect(value, `${label} must be reported before decay can be measured`).toBeDefined(); } // Recover each signal's half-life from the two readings: // value(t) = value(0) * 2^(-t/H) => H = t * ln2 / -ln(v2/v1) const impliedHalfLife = (v1: number, v2: number): number => (elapsedSeconds * Math.LN2) / -Math.log(v2 / v1); const viewHalfLife = impliedHalfLife(viewFirst!, viewSecond!); const likeHalfLife = impliedHalfLife(likeFirst!, likeSecond!); const declaredView = app.declaredDecay.view; const declaredLike = app.declaredDecay.like; expect(typeof declaredView, 'view must declare an exponential half-life').toBe('number'); expect(typeof declaredLike, 'like must declare an exponential half-life').toBe('number'); await recordJson(testInfo, 'decay-recovers-declared-half-life', { elapsedSeconds, declaredDecay: app.declaredDecay, view: { first: viewFirst, second: viewSecond, impliedHalfLifeSeconds: viewHalfLife }, like: { first: likeFirst, second: likeSecond, impliedHalfLifeSeconds: likeHalfLife }, impliedDays: { view: viewHalfLife / 86_400, like: likeHalfLife / 86_400 }, }); expect(viewSecond!, 'the same stored view must report lower on a later read').toBeLessThan( viewFirst!, ); expect(likeSecond!, 'the same stored like must report lower on a later read').toBeLessThan( likeFirst!, ); // +/-20% is generous against a 4-second observation window and still // separates the two decisively: 7d x 1.2 = 8.4d sits well below 14d x 0.8 = 11.2d. const tolerance = 0.2; expect( Math.abs(viewHalfLife - (declaredView as number)) / (declaredView as number), `view decayed as if its half-life were ${(viewHalfLife / 86_400).toFixed(2)}d, but the ` + `loaded schema declares ${((declaredView as number) / 86_400).toFixed(2)}d`, ).toBeLessThan(tolerance); expect( Math.abs(likeHalfLife - (declaredLike as number)) / (declaredLike as number), `like decayed as if its half-life were ${(likeHalfLife / 86_400).toFixed(2)}d, but the ` + `loaded schema declares ${((declaredLike as number) / 86_400).toFixed(2)}d`, ).toBeLessThan(tolerance); // Note on scope: `skip` is declared permanent, and permanence is the one // decay claim this surface cannot show — no built-in profile reports a skip // term at all (see the inertness check above), so there is no value to watch // hold still. Recorded rather than faked. expect(app.declaredDecay.skip, 'skip remains declared permanent').toBe('permanent'); } finally { await app.close(); } }); test('vector search returns the true nearest neighbours, not an approximation', async ({ request, }, testInfo) => { const app = await startApp(); try { const observations: Record[] = []; for (const probe of PROBE_IDS) { const vector = app.groundTruth.probeVectors[String(probe)]; expect(vector, `ground truth carries no query vector for probe ${probe}`).toBeDefined(); expect(vector!.length, 'the query vector must match the declared width').toBe( app.groundTruth.dim, ); // Field is `vector`, not `values` — a wrong name is answered 422, which // reads like a malformed body. Response field is `items`, not `matches`. const response = await request.post(`${app.url}/api/vector_search`, { data: { vector, k: 10 }, }); expect(response.status(), `vector_search for ${probe} must answer`).toBe(200); const body = (await response.json()) as { items?: { entity_id: number; distance: number }[]; }; const items = body.items ?? []; expect( items.length, 'an empty result cannot distinguish a perfect index from a broken one', ).toBeGreaterThan(0); const truth = app.groundTruth.neighboursByItem[String(probe)] ?? []; const returned = items.map((item) => item.entity_id); const compared = Math.min(truth.length, returned.length); observations.push({ probe, selfDistance: items[0]?.distance, returned: returned.slice(0, compared), truth: truth.slice(0, compared), }); // An item's own vector must find the item itself, at ~zero distance. expect(items[0]?.entity_id, `${probe}'s own vector must rank ${probe} first`).toBe(probe); expect( items[0]!.distance, `${probe} found itself at distance ${items[0]!.distance}, beyond the f32 round-trip ` + `tolerance of ${app.groundTruth.selfDistanceTolerance}`, ).toBeLessThan(app.groundTruth.selfDistanceTolerance); // And the whole top-k must equal brute-force cosine over the same corpus, // computed by `tidal-stress`'s own oracle from the same generator that // produced the indexed vectors. expect( returned.slice(0, compared), `ANN top-${compared} for ${probe} diverged from brute-force cosine`, ).toEqual(truth.slice(0, compared)); } await recordJson(testInfo, 'ann-matches-brute-force', { dim: app.groundTruth.dim, tolerance: app.groundTruth.selfDistanceTolerance, observations, }); } finally { await app.close(); } }); test('reported signal values reconcile with what was written, and rank is sequential', async ({ request, }, testInfo) => { const app = await startApp(); try { const likedTwice = 900_000_200; const viewedOnce = 900_000_201; await writeSignal(request, app, likedTwice, 'like'); await writeSignal(request, app, likedTwice, 'like'); await writeSignal(request, app, viewedOnce, 'view'); const items = await readFeed(request, app); const liked = items[positionOf(items, likedTwice)]; const viewed = items[positionOf(items, viewedOnce)]; const likeBoost = signalValue(liked, 'like_boost'); const viewCount = signalValue(viewed, 'view'); const viewBoost = signalValue(viewed, 'view_boost'); const ranks = items.map((item) => item.rank); const expectedRanks = items.map((_unused, index) => index + 1); await recordJson(testInfo, 'explainability-reconciles', { likedTwice: { entityId: likedTwice, likeBoost }, viewedOnce: { entityId: viewedOnce, viewCount, viewBoost }, ranks, }); // Two unit-weight likes report a boost of 2 per like. Decay over the few // milliseconds since the write makes this marginally short of 4, so the // comparison is a tolerance rather than an equality. expect(likeBoost, 'two unit likes must be reported, not silently dropped').toBeDefined(); expect( Math.abs(likeBoost! - 4), `two unit-weight likes reported like_boost ${likeBoost}, expected ~4 (2 per like)`, ).toBeLessThan(0.01); // The raw `view` term is a count, and it must equal the number of writes. expect(viewCount, 'the raw view count must be reported').toBe(1); expect( Math.abs(viewBoost! - 1), `one unit-weight view reported view_boost ${viewBoost}, expected ~1`, ).toBeLessThan(0.01); // Rank must be a dense 1..n sequence. This is the assertion that caught the // duplicate-rank defect on the deployed cluster, where `scatter_merge` // returns a merged slice without re-stamping rank // (`tidal-server/src/cluster/node.rs:7542`). Standalone numbers ranks at // `tidal/src/query/executor/pipeline.rs:611` and is unaffected — the pair of // results is what localises the defect to the merge. // See 11-ranking-integrity.spec.ts for the cluster half. expect( ranks, 'rank must be a dense 1..n sequence; duplicates mean a merge did not re-stamp it', ).toEqual(expectedRanks); } finally { await app.close(); } }); });