audit-conversion / logic.mjs
abullard1's picture
Deploy conversion @ d2b2018-space
0bc8c1a verified
Raw
History Blame Contribute Delete
5.24 kB
/* 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;