Implements tmp/tidaldb-fleet-hardening (20 planned tasks + 2 found by measurement). Ring 0 — restore verification. .woodpecker.yaml step pods ran at the namespace default of 1500m/2Gi, which OOMKilled a prior pipeline and starved the release gate past its budget. Both push-path steps now declare backend_options.kubernetes.resources as two YAML anchors declared once on their first consuming step. The values are CALIBRATED against measured free node capacity, not against the LimitRange max: `requests: cpu 2` (this roadmap's original figure) fits on NO node and would sit Pending forever, because `ci-build-bounds` grants permission and the nodes supply capacity, and those are not the same thing. The `nightly` cron described in this file for 216 days was never created, so tier-3 chaos, the fault classes, mTLS and the PITR test produced exactly zero signal while reading like standing coverage. nightly-chaos and nightly-security-ops now alias the anchors and have budgets matching the gate (their 120/90 were TIGHTER on the same runner, so they would have failed nightly for a budget reason, not a correctness one). nightly-soak is REMOVED, not scheduled: it drives 1000 rps for 600s gating on p99 <= 250ms, and the best node has 1700m free CPU, so it would fail on starvation rather than regression — manufacturing a nightly false alarm. Its commands move verbatim to docs/runbooks/nightly-soak.md. Ring 1 — four fabrications removed from the wire. - scatter_merge sorted and truncated without re-stamping rank, so /feed and /search returned 1,1,2 under full placement. Reuses merge_cross_shard's existing stamp; asserted on BOTH the multi-group merge path and the single-group [only] fast path that bypasses it. - aggregate_region_row's None arm invented `applied_events: 0` plus a deficit derived from it. applied_events/lag_events are now Option<u64>, null on the wire. leader_last_seq was also unwrap_or(0), so a node that could not reach the LEADER computed 0 - applied = 0 for every region and reported a converged cluster it had never measured — a fabrication pointing the dangerous way. - tidalctl inferred NO REPORT from `applied == 0 && lag > 0`. That heuristic was actively hiding the PVC-wipe shape: a measured zero with a real deficit rendered as "no report" instead of BEHIND. Now read off the wire; converged exits 0, partitioned still exits nonzero. - /sharded/* answered 201/204 for single-copy writes with nothing anywhere saying so. Now requires `x-tidal-ack: local`, rejecting with 400 via the existing invalid_input path. Six call sites migrated, not the two this roadmap predicted — including docs/runbooks/cluster.md §16.3, which told operators to run a quorum-write probe via POST /sharded/items. That probe cannot verify quorum: the surface applies locally with no WAL append. It was used as the safety check between every step of a staged deploy earlier today. Ring 2 — observability. JSON_LOGS was already implemented and the deployment simply never asked for it; the StatefulSet now sets it, plus TIDAL_SERVICE_NAME=tidaldb because enabling it silently renames the VictoriaLogs `service` stream field and would have blinded every query keyed on it. Adds tidaldb_usearch_replicated_vectors_total, incremented on BOTH the origin (wal_blob_first -> Ok(Some)) and the follower apply path — counting only the origin would mean each vector lands on exactly one node, replicas never agree, and the alert built on it pages forever. Found by measurement, not planned: the 401 path discarded every fact about every rejection. Traefik has served 101,858 rejected requests to the public ingress — 87.6% of all its traffic — with no record of who or why anywhere. unauthorized_response now emits reason (missing_token vs invalid_token, the distinction that separates a scanner from a rotation that missed a consumer) and the forwarded client. The token is never logged. Also: scripts/restore-fleet.sh --cluster started the soak monitor while deliberately leaving its gate suspended, orphaning a watcher that has reported "0/30 green nights" for 13 days. The pair now moves together. Doc-guard's three-warning backlog is cleared with real backfill for M4/M6/M12. Verified: fmt clean; clippy 5 crates 0 new warnings (74 vs 74 baseline, counted in a detached worktree at HEAD); lib 2110 passed; cluster_sharding 5; cluster_runbook 10; tidalctl 38; doc-guard 0 warnings. Playwright 32/34 with the two remaining failures asserting the rank fix against the not-yet-rolled image — they are the post-deploy proof.
419 lines
18 KiB
TypeScript
419 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: standalone numbers ranks at
|
|
// `tidal/src/query/executor/pipeline.rs:611` and was always dense, while the
|
|
// cluster's `scatter_merge` returned a merged slice without re-stamping — the
|
|
// pair of results is what localised the defect to the merge. `scatter_merge`
|
|
// now stamps too. This assertion guards the ENGINE half; the merge halves are
|
|
// `mp_ranked_reads_stamp_dense_rank_on_both_merge_shapes`
|
|
// (`tidal-server/tests/cluster_sharding.rs`, both the multi-group merge path
|
|
// and the single-group fast path) and `11-ranking-integrity.spec.ts` (the
|
|
// deployed cluster).
|
|
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();
|
|
}
|
|
});
|
|
});
|