tidaldb/tests/e2e/app/public/index.html
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

205 lines
8.2 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Content feed — ranked by tidalDB</title>
<!--
The product surface: a ranked list of items with the signal explainability
tidalDB returned alongside them.
Three rules govern this file, all of them load-bearing:
1. Every request is RELATIVE (`/api/*`). `.sdlc/guidance.md:165` forbids a
hardcoded host in frontend code, and the bearer is injected by the harness
proxy so it never reaches this page. There is no hostname, port or
credential anywhere below.
2. Rows render in ARRAY ORDER. There is no `.sort()` in this file.
`CODING_GUIDELINES.md:88` puts ranking in the database — "the application
never computes trending_score" — and a page that re-sorted would make a
broken server rank look correct on screen, which is the one thing this app
must never do.
3. No timers. The feed is re-read on explicit user action only
(`.sdlc/guidance.md:183`).
It shows the server's own `rank` next to the display position, so the two
disagreeing is visible rather than hidden. On the deployed cluster they DO
disagree (`scatter_merge` does not re-stamp rank); on this standalone node they
agree.
-->
<style>
:root {
--bg: #0d1117; --panel: #131922; --line: #222c3a;
--text: #d6deeb; --dim: #7c8ba1;
--cmd: #79c0ff; --good: #56d364; --expected: #d29922;
}
* { box-sizing: border-box; }
html, body { margin: 0; }
/* Sized so ten rows PLUS the environment footer fit inside 1600x900 — the
capture viewport. Overflowing clipped the footer, and the footer is what
stops a fixture screenshot reading as production. */
body {
background: var(--bg); color: var(--text);
font: 15px/1.55 ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
padding: 28px 44px;
}
/* Capped and centred: at 1600px the row grid stretched the title column to
~780px of empty space before the signal values, which read as a broken
layout rather than a list. */
.wrap { max-width: 1240px; margin: 0 auto; }
header { border-bottom: 1px solid var(--line); padding-bottom: 14px; margin-bottom: 16px; }
h1 {
margin: 0 0 5px; font-size: 25px; letter-spacing: -0.2px; font-weight: 650;
font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
}
.sub {
margin: 0; color: var(--dim); font-size: 14.5px;
font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
}
ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
li {
display: grid; grid-template-columns: 58px 1fr 236px 146px;
align-items: center; gap: 16px;
background: var(--panel); border: 1px solid var(--line);
border-radius: 8px; padding: 9px 16px;
}
/* Only the item that MOVED UP is highlighted. Nine rows shifting down by one
because a tenth was promoted is arithmetic, not nine warnings — amber on all
of them buried the single real event. Amber stays reserved for genuine
negatives. */
li.moved-up { border-color: var(--good); }
.pos { font-size: 22px; line-height: 1.2; font-weight: 700; text-align: right; color: var(--cmd); }
.pos .rank { display: block; font-size: 11px; font-weight: 400; color: var(--dim); }
.pos .rank.disagree { color: var(--expected); }
.title {
font-size: 16px; line-height: 1.35; font-weight: 600;
font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
}
.meta { color: var(--dim); font-size: 12.5px; line-height: 1.35; margin-top: 2px; }
.delta { color: var(--good); font-weight: 700; }
.delta.down { color: var(--dim); font-weight: 400; }
.signals { font-size: 12.5px; line-height: 1.35; color: var(--dim); }
.signals span { display: block; }
.signals b { color: var(--text); font-weight: 600; }
.actions { display: flex; gap: 8px; justify-content: flex-end; }
button {
font: inherit; font-size: 13px; cursor: pointer; padding: 7px 14px;
border-radius: 6px; border: 1px solid var(--line);
background: #1b2430; color: var(--text);
}
button:hover { border-color: var(--cmd); }
button.skip:hover { border-color: var(--expected); }
button[disabled] { opacity: 0.45; cursor: default; }
footer {
margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line);
color: var(--dim); font-size: 13px;
font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
}
#status { color: var(--expected); }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Content feed</h1>
<p class="sub">
Ranked by tidalDB. This page holds the catalog; the database holds the
ranking. Position is whatever order the query returned &mdash; nothing here
sorts.
</p>
</header>
<ol id="feed"></ol>
<footer>
<span id="env">local standalone node &middot; 60-item fixture catalog &middot; profile <code>for_you</code></span><span id="status"></span>
</footer>
</div>
<script type="module">
const LIMIT = Number(new URLSearchParams(location.search).get('limit') ?? 10);
const feedEl = document.getElementById('feed');
const statusEl = document.getElementById('status');
/** The catalog, served from the same contract the seeder used. */
const catalog = await (await fetch('/catalog.json')).json();
const byId = new Map(catalog.map((item) => [item.entityId, item]));
/** Display position of each entity on the previous render, for the delta arrow. */
let previousPositions = new Map();
function signalCell(signals) {
if (signals.length === 0) return '<span>no signals</span>';
return signals
.map((s) => `<span><b>${s.name}</b> ${Number(s.value).toFixed(3)}</span>`)
.join('');
}
/**
* Render in ARRAY ORDER. `moved` is the change in display position since the
* last render — presentation of a delta, not computation of a rank.
*/
function render(items) {
feedEl.replaceChildren(...items.map((row, index) => {
const item = byId.get(row.entity_id);
const was = previousPositions.get(row.entity_id);
const moved = was === undefined ? 0 : was - index;
const signals = row.signals ?? []; // absent, not [], when there are none
const li = document.createElement('li');
if (moved > 0) li.className = 'moved-up';
li.innerHTML = `
<div class="pos">${index + 1}
<span class="rank${row.rank === index + 1 ? '' : ' disagree'}">rank ${row.rank}</span>
</div>
<div>
<div class="title">${item ? item.title : `unknown item ${row.entity_id}`}
${moved !== 0 ? `<span class="delta${moved < 0 ? ' down' : ''}">${moved > 0 ? '\u25b2' : '\u25bc'}${Math.abs(moved)}</span>` : ''}
</div>
<div class="meta">${item ? item.category : ''} &middot; id ${row.entity_id} &middot; score ${Number(row.score).toFixed(3)}</div>
</div>
<div class="signals">${signalCell(signals)}</div>
<div class="actions">
<button data-id="${row.entity_id}" data-signal="like">Like</button>
<button class="skip" data-id="${row.entity_id}" data-signal="skip">Skip</button>
</div>`;
return li;
}));
previousPositions = new Map(items.map((row, index) => [row.entity_id, index]));
}
async function refresh() {
const response = await fetch(`/api/feed?profile=for_you&limit=${LIMIT}`);
if (!response.ok) {
statusEl.textContent = ` \u2014 feed failed: HTTP ${response.status}`;
return;
}
render((await response.json()).items ?? []);
}
/** One signal write, then exactly one re-read. No polling, no timer. */
async function sendSignal(entityId, signal) {
for (const button of feedEl.querySelectorAll('button')) button.disabled = true;
const response = await fetch('/api/signals', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ entity_id: Number(entityId), signal, weight: 1.0 }),
});
statusEl.textContent = response.ok
? ` \u2014 wrote ${signal} on ${entityId} (HTTP ${response.status})`
: ` \u2014 ${signal} on ${entityId} failed: HTTP ${response.status}`;
await refresh();
}
feedEl.addEventListener('click', (event) => {
const button = event.target.closest('button[data-signal]');
if (button) void sendSignal(button.dataset.id, button.dataset.signal);
});
await refresh();
</script>
</body>
</html>