/** * Section 11 — ranking integrity on the deployed cluster. * * `rank` must be a dense ascending `1..n` over the returned page, on every * corpus-wide ranked response the cluster serves. This is the deployed-artifact * half of the dense-rank contract, which is why it lives here rather than only in * the hermetic suites: the defect it guards existed ONLY under full placement, the * production shape. * * ## What this file used to be * * A tripwire pair, asserting that `rank` was WRONG and instructing its own * deletion once fixed. `scatter_merge` (`tidal-server/src/cluster/node.rs`) sorted * and truncated the concatenated per-group slices but never re-stamped `rank`, * while its sibling `merge_cross_shard` did — and full placement returns before * reaching the sibling. Each hosted group ranked its own slice `1..k` locally, so * live `/search?query=verification` answered `1, 1, 2`. * * `scatter_merge` now takes the same `set_rank` closure and stamps after * `truncate`. The tripwires are gone; this positive assertion replaces them, so * the deployed cluster keeps a dense-rank guard instead of the fix silently * regressing on the one shape only a real deployment exercises. * * Coverage split, all three layers: * - engine ranking — `10-ranking-semantics.spec.ts` (hermetic standalone, 1..60); * - BOTH merge shapes — `mp_ranked_reads_stamp_dense_rank_on_both_merge_shapes` * in `tidal-server/tests/cluster_sharding.rs` (real 3-process clusters at * 3 groups AND 1 group, so the `[only]` fast path is asserted, not assumed); * - the deployed cluster — this file. * * Read-only. Nothing here writes to the deployed cluster. */ import { expect, test, type APIRequestContext } from '@playwright/test'; import { recordJson } from '../support/evidence.ts'; import { PUBLIC_BASE_URL, apiKey } from '../support/env.ts'; type RankedItem = { entity_id: number; score: number; rank: number }; /** * Enough rows that at least two shard groups must both contribute. With three * groups a limit of 1 or 2 can be answered from one group, and a per-group * counter that was never re-stamped would show no duplicate at all. */ const LIMIT = 12; /** What a correctly stamped response looks like: dense, ascending, from 1. */ const denseRanks = (count: number): number[] => Array.from({ length: count }, (_unused, index) => index + 1); async function ranked( request: APIRequestContext, path: string, ): Promise<{ items: RankedItem[]; ranks: number[]; scores: number[] }> { const response = await request.get(`${PUBLIC_BASE_URL}${path}`, { headers: { authorization: `Bearer ${apiKey()}` }, }); expect(response.status(), `${path} must answer before any conclusion is drawn`).toBe(200); const body = (await response.json()) as { items?: RankedItem[] }; const items = body.items ?? []; expect( items.length, 'an empty result cannot distinguish correct ranking from broken ranking', ).toBeGreaterThan(1); return { items, ranks: items.map((item) => item.rank), scores: items.map((item) => item.score), }; } /** * Scores must stay descending. The rank stamp is a renumbering, never a re-sort, * so an ordering change here is a DIFFERENT and worse defect than the one this * file used to pin: it would mean the merge's sort itself broke. */ function expectScoresOrdered(scores: number[], surface: string): void { for (let index = 1; index < scores.length; index += 1) { expect( scores[index]!, `${surface} returned scores out of order at position ${index} ` + `(${scores[index - 1]} then ${scores[index]}). The rank stamp must not re-sort.`, ).toBeLessThanOrEqual(scores[index - 1]!); } } const REGRESSION_HINT = 'Duplicate or zero ranks are the signature of per-group slices merged without a ' + 're-stamp. Check that scatter_merge still stamps after truncate (and that the ' + 'single-group [only] fast path still returns the engine order).'; /** * The two corpus-wide ranked surfaces. Both return through the same gateway * merge, so asserting both is what shows the stamp lives in the merge rather than * in one query pipeline. * * `/search` uses a term known to be indexed on this corpus: the production corpus * has no titles for its 33k items, so an arbitrary word returns zero candidates * and would make the check vacuous. */ const RANKED_SURFACES: Record = { '/feed': `/feed?profile=for_you&limit=${LIMIT}`, '/search': `/search?query=verification&limit=${LIMIT}`, }; test.describe('section 11 — ranking integrity (deployed cluster)', () => { for (const [surface, path] of Object.entries(RANKED_SURFACES)) { test(`cluster ${surface} returns dense ascending ranks`, async ({ request }, testInfo) => { const { items, ranks, scores } = await ranked(request, path); await recordJson(testInfo, `cluster-ranks${surface.replace('/', '-')}`, { surface: path, ranks, expected: denseRanks(ranks.length), duplicateCount: ranks.length - new Set(ranks).size, scores, entityIds: items.map((item) => item.entity_id), }); expect(ranks, `${surface}: ${REGRESSION_HINT}`).toEqual(denseRanks(ranks.length)); expectScoresOrdered(scores, surface); }); } });