Spaces:
Running
Running
| /* 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; | |