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.
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
/**
|
|
* Section 11 — ranking integrity on the deployed cluster.
|
|
*
|
|
* A tripwire pair, in the honest direction. `rank` is currently WRONG on every
|
|
* corpus-wide ranked response from the cluster, and these checks pin that with
|
|
* its root cause so the fix announces itself instead of the defect persisting
|
|
* silently. Same shape as the inert-feature checks in
|
|
* `09-operator-authority.spec.ts`.
|
|
*
|
|
* Root cause, localised: `scatter_merge`
|
|
* (`tidal-server/src/cluster/node.rs:7471`) concatenates each shard group's
|
|
* locally-ranked slice, sorts by score, truncates, and returns at `:7542`
|
|
* WITHOUT re-stamping rank. Its sibling `merge_cross_shard` (`:7583`) does
|
|
* re-stamp, at `:7609`, with a comment naming this exact hazard — but `:7621`
|
|
* documents that full placement short-circuits past it, and this cluster is
|
|
* full-placement RF3, so the guarded path never runs.
|
|
*
|
|
* The evidence that it is the MERGE and not the engine is two-sided:
|
|
* - standalone numbers ranks densely (`10-ranking-semantics.spec.ts`, measured
|
|
* 1..60 with no gaps) via `tidal/src/query/executor/pipeline.rs:611`;
|
|
* - the cluster returns per-group ranks concatenated, while SCORES remain
|
|
* correctly ordered — so the merge's sort is fine and only the stamp is absent.
|
|
*
|
|
* 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 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),
|
|
};
|
|
}
|
|
|
|
/** The order is right even though the stamp is wrong — the precise localisation. */
|
|
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]}). That is a DIFFERENT and worse ` +
|
|
`defect than the rank stamp: it would mean the merge's sort is broken too.`,
|
|
).toBeLessThanOrEqual(scores[index - 1]!);
|
|
}
|
|
}
|
|
|
|
const FIX_INSTRUCTION =
|
|
'Good news: scatter_merge appears to be fixed. Delete this tripwire, keep the ' +
|
|
'dense-rank assertion in 10-ranking-semantics.spec.ts, and close the defect.';
|
|
|
|
test.describe('section 11 — ranking integrity (cluster tripwires)', () => {
|
|
test('cluster /feed rank is duplicated — pinned defect in scatter_merge', async ({
|
|
request,
|
|
}, testInfo) => {
|
|
const { items, ranks, scores } = await ranked(
|
|
request,
|
|
`/feed?profile=for_you&limit=${LIMIT}`,
|
|
);
|
|
const duplicates = ranks.length - new Set(ranks).size;
|
|
|
|
await recordJson(testInfo, 'cluster-feed-ranks', {
|
|
surface: `/feed?profile=for_you&limit=${LIMIT}`,
|
|
ranks,
|
|
expectedIfFixed: denseRanks(ranks.length),
|
|
duplicateCount: duplicates,
|
|
scores,
|
|
entityIds: items.map((item) => item.entity_id),
|
|
rootCause: 'tidal-server/src/cluster/node.rs:7542 (scatter_merge returns without set_rank)',
|
|
});
|
|
|
|
expect(ranks, FIX_INSTRUCTION).not.toEqual(denseRanks(ranks.length));
|
|
expect(
|
|
duplicates,
|
|
'expected duplicate ranks — the signature of per-group slices merged without ' +
|
|
`a re-stamp. ${FIX_INSTRUCTION}`,
|
|
).toBeGreaterThan(0);
|
|
|
|
// Ordering is correct; only the stamp is missing. If this ever fails, the
|
|
// defect has become materially worse.
|
|
expectScoresOrdered(scores, '/feed');
|
|
});
|
|
|
|
test('cluster /search rank is duplicated the same way, from the same merge', async ({
|
|
request,
|
|
}, testInfo) => {
|
|
// 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 this check vacuous.
|
|
const { items, ranks, scores } = await ranked(
|
|
request,
|
|
`/search?query=verification&limit=${LIMIT}`,
|
|
);
|
|
const duplicates = ranks.length - new Set(ranks).size;
|
|
|
|
await recordJson(testInfo, 'cluster-search-ranks', {
|
|
surface: `/search?query=verification&limit=${LIMIT}`,
|
|
ranks,
|
|
expectedIfFixed: denseRanks(ranks.length),
|
|
duplicateCount: duplicates,
|
|
scores,
|
|
entityIds: items.map((item) => item.entity_id),
|
|
});
|
|
|
|
// `/search` shares the same gateway merge, so the same defect surfaces here.
|
|
// Asserting it on both surfaces is what shows the fault is in the merge
|
|
// rather than in one query pipeline.
|
|
expect(ranks, FIX_INSTRUCTION).not.toEqual(denseRanks(ranks.length));
|
|
expect(
|
|
duplicates,
|
|
`expected duplicate ranks on /search too. ${FIX_INSTRUCTION}`,
|
|
).toBeGreaterThan(0);
|
|
expectScoresOrdered(scores, '/search');
|
|
});
|
|
});
|