/* Pure logic for the audit engine — zero imports, no DOM, no network, no * globals. Everything here is unit-tested in Node (tools/spaces/tests/) and * holds the invariants that protect the data: * - the newest judgment per item is decided by FILE POSITION, never clocks; * - a merge is an order-preserving union — it can only grow the log; * - per-user item order is a pure function of (task, user) and must stay * bit-identical across deploys, or resume positions shift under people. */ export const keyOf = (item) => String(item.id ?? item.key); export const latestByKey = (events) => { const map = new Map(); for (const ev of events) map.set(ev.key, ev); // log order: later position wins return map; }; // Union preserving log order: remote rows first (their order is the published // history), then local rows not yet in remote, in local append order. No ts // sort — a skewed clock must never reorder a revision before its original. export function mergeEvents(remote, local) { const seen = new Set(remote.map((ev) => ev.key + "|" + ev.ts)); return [...remote, ...local.filter((ev) => !seen.has(ev.key + "|" + ev.ts))]; } // Seeded per-user order (fatigue decorrelation) — mulberry32 over a string hash. export function seededOrder(n, seed) { let h = 1779033703; for (const ch of seed) { h = Math.imul(h ^ ch.charCodeAt(0), 3432918353); h = (h << 13) | (h >>> 19); } const rand = () => { h = Math.imul(h ^ (h >>> 16), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; }; const order = [...Array(n).keys()]; for (let i = n - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [order[i], order[j]] = [order[j], order[i]]; } return order; } // Warm-ups always come first, in file order; real items follow in seeded order. export function orderItems(items, seed) { const warm = [], real = []; items.forEach((item, i) => (item.warmup ? warm : real).push(i)); const shuffled = seededOrder(real.length, seed).map((i) => real[i]); return [...warm, ...shuffled]; } export function firstUngraded(order, items, events, isComplete) { const done = latestByKey(events); for (let pos = 0; pos < order.length; pos++) { const ev = done.get(keyOf(items[order[pos]])); if (!ev || !isComplete(ev)) return pos; } return order.length; } // The ordering gate's completeness count: latest non-warmup events carrying // every required field. Blanks and warm-ups do not count. export function countComplete(rows, fields) { return [...latestByKey(rows).values()] .filter((ev) => !ev.warmup && fields.every((f) => ev[f])).length; } export function stampClass(values) { // The stamp inks in the verdict's own color; any failing answer turns it red. const vals = Object.values(values); if (vals.includes("no_match") || vals.includes("no")) return "v-no_match"; if (vals.includes("no_answer") || vals.includes("borderline")) return "v-no_answer"; return "v-match"; } // ── one-tab ownership epochs ───────────────────────────────────────────────── // Taking over bumps the epoch; a tab stands down only to a FRESH foreign // heartbeat of an equal-or-newer epoch — so a seizure is one-directional even // while the old tab is still beating. export const nextEpoch = (stored, force) => (stored?.epoch ?? 0) + (force ? 1 : 0); export function isForeignFresh(hb, sessionId, now, freshMs) { return !!hb && hb.session !== sessionId && now - hb.at < freshMs; } export function standsDownTo(hb, sessionId, myEpoch, now, freshMs) { return isForeignFresh(hb, sessionId, now, freshMs) && (hb.epoch ?? 0) >= myEpoch; } // ── error/status classification (HF error prose carries no status digits — // branch on HubApiError.statusCode, never on message text) ──────────────── export const statusOf = (err) => Number.isInteger(err?.statusCode) ? err.statusCode : Number((String(err).match(/\b(40\d|409|412|5\d{2})\b/) ?? [])[1]) || null; // Repo-existence probe outcomes, from a raw fetch status. export const classifyProbe = (status) => status === 200 ? "present" : status === 404 ? "absent" : (status === 401 || status === 403) ? "denied" : "network"; // A cached token is only usable if it still carries every scope the app now // requires — scopes changed mid-study, and a stale grant fails at publish. export const hasScopes = (grantedString, required) => { const granted = new Set(String(grantedString ?? "").split(/\s+/)); return required.every((scope) => granted.has(scope)); }; // On-screen item label. Some tasks must not reveal their key's structure — // calibration keys are "qid:rep", so five repeats of one question would be // recognisable and put the annotator under consistency pressure the design // otherwise avoids. The STORED key is always the real one; this is display // only, derived from the annotator's own seeded position so two repeats never // share a visible prefix. export const displayId = (key, position, opaque) => opaque ? `${opaque}-${String(position + 1).padStart(3, "0")}` : key;