tidaldb/tests/e2e/features/10-ranking-semantics.spec.ts
jordan 9523f6da43 test(e2e): verify ranking semantics with a content-feed app, and route three product findings
The existing 32 checks prove the deployment answers -- TLS, auth, quorum commit,
convergence, isolation, dashboards, backups. Not one wrote a signal and observed
an order change, so VISION.md:17 "Ranking is not a feature. It is a primitive."
was unverified. This adds a 60-item content-feed app and five assertions that
verify the product's semantics, on a hermetic standalone node.

Added
- tests/e2e/app/: fixture contract (60 items, 4 categories, each owning one
  unoccupied 100-id embedding cluster), a deep-module harness owning the whole
  lifecycle behind startApp(), the product page, and an app:dev entry point.
- tidal-stress/src/bin/feed-fixture.rs: seeds the catalog and emits brute-force
  ground truth, reusing recall::embedding_for rather than adding a third copy of
  the corpus generator (tidal/src/db/items.rs already holds a second).
- GroundTruth::from_ids: the oracle now serves sparse id sets. build() delegates,
  so there is no transient copy even at 1M, and top_k indexes positionally.
- 10-ranking-semantics.spec.ts (5 hermetic checks) and
  11-ranking-integrity.spec.ts (2 cluster tripwires).
- playwright.semantics.config.ts + CAP-016 demo beat (walkthrough 82s -> 90s).

Measured, not merely green
- like: index 59 -> 0, like_boost 2.0, with no sleep between write and read.
- decay: implied half-lives 7.0007 d and 14.0014 d against a schema declaring
  7 d and 14 d, recovered from a 4-second window via H = t*ln2 / -ln(v2/v1) and
  compared against the schema the node actually loaded, not a hardcoded copy.
- ANN: top-10 identical to brute-force cosine on all four probes; self-distance
  0.0148-0.0197 against a 0.05 tolerance.
- rank: dense 1..60 on standalone vs [1,1,1,2,2,3,4,3,4,5,6,5] on the cluster.

Three product findings, pinned and routed to @tidal-engineer
- BUG-018 (High) skip is durably accepted and query-time inert. Penalty is fully
  implemented (ranking/profile.rs:227 -> executor/signal_values.rs:183, labelled
  {signal}_penalty at executor/mod.rs:65) but skeleton() sets penalties: vec![]
  (ranking/builtins.rs:62) and none of the 27 built-ins overrides it. So
  VISION.md:187 "negative signals are equal citizens" holds for no shipped
  profile. Same anti-pattern as the reseed defects and scatter_merge: a guard
  present on one path, absent on its sibling.
- BUG-019 (Medium) three built-ins read signals this schema does not declare --
  trending/share_velocity, hidden_gems/completion, controversial/dislike -- so
  those terms are permanently 0 and trending ranks on view_velocity alone.
- BUG-020 (Low) for_you declares Scan{sort_field:"created_at"} but ignores a
  created_at metadata value; an order matching neither id-asc nor
  created_at-desc came back strictly id-ascending.

Two assertions therefore report a gap rather than a success, written as tripwires
whose failure message says what to do when the gap closes. The rank defect is
localised, not fixed: scatter_merge (cluster/node.rs:7542) returns a merged slice
without re-stamping rank while scores stay correctly ordered, so the fault is the
missing stamp and not the merge's sort.

Notes
- Hermetic by construction: its own config, because FullConfig.projects is not
  filtered by --project and globalSetup publishes credentials into the main
  process that forked workers inherit -- so a setup project cannot replace it,
  and weakening globalSetup would destroy the fail-loud behaviour that is its
  purpose. Verified with KUBECONFIG=/nonexistent and all E2E_* unset.
- Never touches the deployed corpus: skip is permanent: true, so seeding it into
  production would be irreversible.
- The page contains no sort, no hostname and no credential; the harness proxy
  injects auth server-side so no bearer reaches a browser or a capture.
- Schema comes from k8s/cluster/schema-configmap.yaml, asserted at 1536 dims;
  tidal-server/config/default-schema.yaml declares 128 and would 422 every write.

Verification: 5 semantics + 34 regression + 10 demo captures green; tsc clean;
tidal-stress clippy clean under clippy::all=deny with unwrap_used=deny; 2101
tidaldb lib tests; preflight 10/10 perfect; render 90.05s/2700 frames with zero
empty boundary frames; zero orphan processes or temp dirs after teardown.
2026-08-23 22:42:02 -06:00

416 lines
18 KiB
TypeScript

/**
* 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<FeedItem[]> {
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<void> {
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<string, number> = {};
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<string, number> = {};
const reported: Record<string, string[]> = {};
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<string, unknown>[] = [];
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();
}
});
});