/* * sux shared logic — pure functions, no extension APIs. * Loaded as a classic script in the background page, content script, and sidebar, * and require()-able from Node for the unit tests. */ (function (global) { "use strict"; var TEXT_CAP = 2000; var EX_CAP = 10; // newest example/counter posts sent to Jev per rule var EX_TEXT_CAP = 500; var WORD_CAP = 80; // Build-time switch for the local-model feature (SUX-11). Off in current // builds: the in-browser runtime is delivered separately, and until it // registers an embedder the Local UI is a dead end. With LOCAL_MODEL off, // settings.mode collapses to "both" — which, without a runtime, behaves // exactly like Jev-only — and the sidebar hides the chooser. Flip to true // in a build that ships the runtime. var LOCAL_MODEL = false; function cleanWord(w) { if (typeof w !== "string") return ""; return w.trim().toLowerCase().slice(0, WORD_CAP); } function normalizeClauseA(c) { c = c || {}; return { enabled: !!c.enabled, word1: cleanWord(c.word1), word2: cleanWord(c.word2), trained: normalizeTrained(c.trained), }; } function normalizeClauseB(c) { c = c || {}; return { enabled: !!c.enabled, word: cleanWord(c.word), above: c.above !== false, trained: normalizeTrained(c.trained), }; } // SUX-9: word rules accumulate a local separator from Jev's confident // answers — hide-side posts (the rule would hide them) and keep-side posts // (it would show them). Once both sides exist, the separator scores new // posts locally and Jev calls fall toward zero. function normalizeTrained(t) { t = t || {}; var clean = function (list) { return (Array.isArray(list) ? list : []) .map(normalizeExample) .filter(function (x) { return x.text.trim() || x.alt.trim(); }); }; return { hide: clean(t.hide), keep: clean(t.keep) }; } function normalizeExample(x) { x = x || {}; var ex = { text: typeof x.text === "string" ? x.text.slice(0, TEXT_CAP) : "", alt: typeof x.alt === "string" ? x.alt.slice(0, TEXT_CAP) : "", }; if (Array.isArray(x.emb) && x.emb.every(function (v) { return typeof v === "number" && isFinite(v); })) { ex.emb = x.emb.slice(); } return ex; } // SUX-4 rule type E: "hide posts like these". Built from a handful of // example posts the user doesn't want to see; scored locally against the // feed average (SUX-3). `cutoff` is what SUX-5 sets automatically; until // then 0 (score > 0 hides) matches the bench's flag-if-positive rule. // `counters` (SUX-6) are posts the user peeked and said "not this" — they // sharpen the "not this" side of scoring; they never replace the feed side. function normalizeClauseE(c) { c = c || {}; return { enabled: !!c.enabled, name: typeof c.name === "string" ? c.name.trim().slice(0, WORD_CAP) : "", examples: (Array.isArray(c.examples) ? c.examples : []) .map(normalizeExample) .filter(function (x) { return x.text.trim() || x.alt.trim(); }), counters: (Array.isArray(c.counters) ? c.counters : []) .map(normalizeExample) .filter(function (x) { return x.text.trim() || x.alt.trim(); }), cutoff: typeof c.cutoff === "number" && isFinite(c.cutoff) ? c.cutoff : 0, strictness: typeof c.strictness === "number" && c.strictness >= 0 && c.strictness <= 1 ? c.strictness : 0.5, }; } // Canonical settings shape: arbitrary clauses per rule type. Settings stored // by older builds had single ruleA/ruleB objects; those migrate to one-element // arrays so saved filters keep working. function normalizeSettings(s) { s = s || {}; var aSrc = Array.isArray(s.rulesA) ? s.rulesA : (s.ruleA ? [s.ruleA] : []); var bSrc = Array.isArray(s.rulesB) ? s.rulesB : (s.ruleB ? [s.ruleB] : []); return { enabled: !!s.enabled, apiKey: typeof s.apiKey === "string" ? s.apiKey : "", combine: s.combine === "or" ? "or" : "and", peek: !!s.peek, // SUX-6: hidden posts collapse to placeholders instead of vanishing // SUX-11: which backends may run. "local" = never call Jev (no network); // "jev" = Jev judges everything; "both" = the SUX-7 hybrid (local first, // Jev for the unsure). Fail-open stays: a backend that can't run shows // posts. Gated by the LOCAL_MODEL build switch — with the switch off, // mode collapses to "both" and the sidebar hides the chooser. mode: LOCAL_MODEL && (s.mode === "local" || s.mode === "jev") ? s.mode : "both", rulesA: aSrc.map(normalizeClauseA), rulesB: bSrc.map(normalizeClauseB), rulesE: (Array.isArray(s.rulesE) ? s.rulesE : []).map(normalizeClauseE), }; } function modeAllowsJev(mode) { return mode !== "local"; } function modeAllowsLocal(mode) { return mode !== "jev"; } // SUX-11: the runtime reports download/init progress through here; the // background mirrors it into getStats and the sidebar shows the bar. var modelProgress = { pct: 0, status: "idle", at: 0 }; function reportModelProgress(pct, status) { modelProgress = { pct: typeof pct === "number" && pct >= 0 && pct <= 100 ? pct : 0, status: typeof status === "string" ? status.slice(0, 80) : "", at: Date.now(), }; } function getModelProgress() { return modelProgress; } function defaults() { return normalizeSettings(null); } // A clause needs two distinct usable words. "neither" is reserved as the // Choice's third option, so a word colliding with it would silently overwrite // that option. function clauseAValid(c) { return !!c.word1 && !!c.word2 && c.word1 !== c.word2 && c.word1 !== "neither" && c.word2 !== "neither"; } function clauseBValid(c) { return !!c.word; } // An example rule needs at least one example to compare against. function clauseEValid(c) { return c.examples.length > 0; } // True when the filter should actually run: master on AND at least one usable // enabled clause. function isActive(st) { return st.enabled && (st.rulesA.some(function (c) { return c.enabled && clauseAValid(c); }) || st.rulesB.some(function (c) { return c.enabled && clauseBValid(c); }) || st.rulesE.some(function (c) { return c.enabled && clauseEValid(c); })); } // FNV-1a — small, fast, dependency-free; collisions only cause a shared verdict, // which identical config+content would produce anyway with overwhelming likelihood. function hash(str) { var h = 0x811c9dc5; for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return (h >>> 0).toString(16); } // Cache key = clause config + post content. Changing any word or the combinator // invalidates old verdicts; reposts with identical text share one key (and one // API call) because the post id is deliberately not part of the key. Disabled // or invalid clauses keep their slot ("") so array indexes stay aligned with // the question/detail keys. Example rules hash their examples plus the model // id — swap the model or the examples and every verdict is recomputed. function cacheKey(st, text, alt, modelId) { // Trained data (SUX-9) changes local scores, so it joins the key: a rule // that has learned from Jev must not serve verdicts cached before it learned. var trainedHash = function (c) { var t = c.trained || { hide: [], keep: [] }; return hash(t.hide.length + ":" + t.keep.length); }; var cfg = st.rulesA.map(function (c, i) { return c.enabled && clauseAValid(c) ? "A" + i + ":" + c.word1 + "|" + c.word2 + "|" + trainedHash(c) : ""; }); st.rulesB.forEach(function (c, i) { cfg.push(c.enabled && clauseBValid(c) ? "B" + i + ":" + c.word + "|" + (c.above ? "above" : "below") + "|" + trainedHash(c) : ""); }); st.rulesE.forEach(function (c, i) { if (!c.enabled || !clauseEValid(c)) { cfg.push(""); return; } var ex = c.examples.map(function (x) { return x.text + "" + x.alt; }).join(""); var cx = (c.counters || []).map(function (x) { return x.text + "\x01" + x.alt; }).join("\x02"); cfg.push("E" + i + ":" + hash(ex) + "|" + hash(cx) + "|" + c.cutoff + "|" + c.strictness + "|" + (modelId || "")); }); cfg.push(st.combine); return hash(cfg.join(";")) + ":" + hash((text || "") + "\n" + (alt || "")); } // Per-clause score cache keys: one entry per (clause config, post content), // so editing or toggling one clause leaves every other clause's cached // scores usable. The key holds only what changes that clause's score — // rule B's direction and the combinator are applied at decide time, so // flipping them costs no calls. Index-free, so reordering clauses is free // too. Returns [{id: "A0", score: "p0", key}] for enabled valid clauses. function clauseScoreKeys(st, text, alt, modelId) { var post = hash((text || "") + "\n" + (alt || "")); var trained = function (c) { var t = c.trained || { hide: [], keep: [] }; return t.hide.length + ":" + t.keep.length; }; var out = []; st.rulesA.forEach(function (c, i) { if (!c.enabled || !clauseAValid(c)) return; out.push({ id: "A" + i, score: "p" + i, key: "A" + hash(c.word1 + "|" + c.word2 + "|" + trained(c) + "|" + (modelId || "")) + ":" + post }); }); st.rulesB.forEach(function (c, i) { if (!c.enabled || !clauseBValid(c)) return; out.push({ id: "B" + i, score: "n" + i, key: "B" + hash(c.word + "|" + trained(c) + "|" + (modelId || "")) + ":" + post }); }); st.rulesE.forEach(function (c, i) { if (!c.enabled || !clauseEValid(c)) return; var ex = c.examples.map(function (x) { return x.text + "\x01" + x.alt; }).join("\x02"); var cx = (c.counters || []).map(function (x) { return x.text + "\x01" + x.alt; }).join("\x02"); out.push({ id: "E" + i, score: "e" + i, key: "E" + hash(ex + "|" + cx + "|" + c.cutoff + "|" + c.strictness + "|" + (modelId || "")) + ":" + post }); }); return out; } function isJudgeable(post) { return !!((post.text && post.text.trim()) || (post.alt && post.alt.trim())); } // Jev questions, one per enabled valid clause, keyed compare/single/ // example so clauses of the same type stay distinguishable. Wording // follows the jev skill: one property per question, named state path in // backticks, contrastive criteria, no policy in the question (thresholds // live in decideFromScores). Example-rule criteria describe the rule's // intent; the example posts themselves are not sent. // // `only` (SUX-7) restricts to the listed clause ids ("A0", "B1", "E2") so // an escalation round-trips just the unsure clauses; omitted → all. // // `ref` names the post's state path ("post" by default); batched requests // put several posts in one state as post0, post1, …. function buildQuestions(st, only, ref) { var want = only || null; var at = "`" + (ref || "post") + "`"; var wants = function (id) { return !want || want.indexOf(id) !== -1; }; var q = {}; st.rulesA.forEach(function (c, i) { if (!c.enabled || !clauseAValid(c) || !wants("A" + i)) return; var w1 = c.word1; var w2 = c.word2; var cmp = { type: "choice", instructions: "Which option best describes the main topic or overall vibe of the post in " + at + "? Judge the post's own words only.", }; cmp.criteria = {}; cmp.criteria[w1] = "The post is mainly about " + w1 + ", or mainly expresses " + w1 + "."; cmp.criteria[w2] = "The post is mainly about " + w2 + ", or mainly expresses " + w2 + "."; cmp.criteria.neither = "The post is about neither " + w1 + " nor " + w2 + ", is about both equally, or it is too unclear to tell."; q["compare" + i] = cmp; }); st.rulesB.forEach(function (c, i) { if (!c.enabled || !clauseBValid(c) || !wants("B" + i)) return; var w = c.word; q["single" + i] = { type: "noul", instructions: "Does the post in " + at + " read as \"" + w + "\"? Judge the post's own words only.", criteria: { true: "The post is mainly about " + w + " or clearly expresses " + w + ".", false: "The post is not about " + w + ", or only mentions it in passing.", }, }; }); st.rulesE.forEach(function (c, i) { if (!c.enabled || !clauseEValid(c) || !wants("E" + i)) return; var name = c.name || "the hidden examples"; q["example" + i] = { type: "noul", instructions: "The user has a rule (\"" + name + "\") built from example posts they don't want to see: `rule" + i + ".examples`. Posts in `rule" + i + ".not_like` were wrongly matched before — they are NOT that kind of post. Does the post in " + at + " match the kind of post the examples represent? Judge the post's own words only.", criteria: { true: "The post is the same kind of post as the rule's examples — someone who hid those would want this hidden too.", false: "The post is a different kind of post, or it only superficially resembles the examples.", }, }; }); return q; } function buildRequest(st, post, only) { var req = { state: { post: { text: (post.text || "").slice(0, TEXT_CAP), alt: (post.alt || "").slice(0, TEXT_CAP), }, }, model: "jev-latest", questions: buildQuestions(st, only), }; // Example rules: Jev needs the examples themselves, not just the name. // Capped so a long-lived rule can't balloon every request. Object.keys(req.questions).forEach(function (k) { var m = /^example(\d+)$/.exec(k); if (!m) return; var c = st.rulesE[+m[1]]; req.state["rule" + m[1]] = { examples: c.examples.slice(-EX_CAP).map(exampleForJev), not_like: (c.counters || []).slice(-EX_CAP).map(exampleForJev), }; }); return req; } // Several posts in one call (one state, one question set per post). Jev // evaluates every question independently against the shared state, so a // batch costs one round-trip instead of one per post. items: [{post, only}]. // Questions are keyed j_; splitBatchAnswers undoes that. function buildBatchRequest(st, items) { var req = { state: {}, model: "jev-latest", questions: {} }; items.forEach(function (it, k) { req.state["post" + k] = { text: (it.post.text || "").slice(0, TEXT_CAP), alt: (it.post.alt || "").slice(0, TEXT_CAP), }; var q = buildQuestions(st, it.only, "post" + k); Object.keys(q).forEach(function (name) { var m = /^example(\d+)$/.exec(name); if (m && !req.state["rule" + m[1]]) { var c = st.rulesE[+m[1]]; req.state["rule" + m[1]] = { examples: c.examples.slice(-EX_CAP).map(exampleForJev), not_like: (c.counters || []).slice(-EX_CAP).map(exampleForJev), }; } req.questions["j" + k + "_" + name] = q[name]; }); }); return req; } // Per-post answer maps (unprefixed, as jevToScores expects) from a batch. function splitBatchAnswers(answers, n) { var out = []; for (var k = 0; k < n; k++) out.push({}); Object.keys(answers || {}).forEach(function (name) { var m = /^j(\d+)_(.+)$/.exec(name); if (m && +m[1] < n) out[+m[1]][m[2]] = answers[name]; }); return out; } function exampleForJev(x) { var t = (x.text || "").slice(0, EX_TEXT_CAP); return x.alt ? t + " [image: " + x.alt.slice(0, EX_TEXT_CAP) + "]" : t; } // Reduce Jev's answers to a plain per-rule score map: p = P(word1) for // clause A, n = Noul for clause B (example-based rules will add // e). This is answerDetails without the rounding — the seam every // backend (Jev, local embeddings, a mix) feeds into decideFromScores. // Clauses with no usable answer are omitted; decideFromScores treats a // missing score as "no verdict". function jevToScores(st, answers) { var scores = {}; for (var i = 0; i < st.rulesA.length; i++) { var a = st.rulesA[i]; if (!a.enabled || !clauseAValid(a)) continue; var probs = answers && answers["compare" + i] && answers["compare" + i].probabilities; var p = probs && typeof probs[a.word1] === "number" ? probs[a.word1] : null; if (p !== null) scores["p" + i] = p; } for (var j = 0; j < st.rulesB.length; j++) { var b = st.rulesB[j]; if (!b.enabled || !clauseBValid(b)) continue; var single = answers && answers["single" + j]; var n = single && typeof single.noul === "number" ? single.noul : null; if (n !== null) scores["n" + j] = n; } // SUX-7: an escalated example rule comes back as example.noul, a 0..1 // probability on Jev's scale. decideFromScores compares e against the // clause's local cutoff (a cosine-gap scale), so map Jev's confidence onto // that scale: p=1 → a full ESC_MARGIN past the cutoff on the hide side, // p=0 → a full margin on the show side. The verdict is Jev's either way. for (var k = 0; k < st.rulesE.length; k++) { var e = st.rulesE[k]; if (!e.enabled || !clauseEValid(e)) continue; var exAns = answers && answers["example" + k]; var jp = exAns && typeof exAns.noul === "number" ? exAns.noul : null; if (jp !== null) scores["e" + k] = escalatedToLocalScale(jp, effectiveCutoff(e)); } return scores; } // Decide from a plain score map (any backend) — "show" | "hide" | null. // Null means an enabled clause had no score (transient/malformed backend // output) — callers fail open in that case. // // Score keys: p = P(word1) for clause A, n = Noul for clause B, // e = similarity-minus-feed for clause E (higher = more like the // examples = more hideable). An E clause hides at or above its effective // cutoff (SUX-5: auto leave-one-out value shifted by the strictness slider). function decideFromScores(st, scores) { var results = []; for (var i = 0; i < st.rulesA.length; i++) { var a = st.rulesA[i]; if (!a.enabled || !clauseAValid(a)) continue; var p = scores && typeof scores["p" + i] === "number" ? scores["p" + i] : null; if (p === null) return null; results.push(p >= 0.5); // "at least 50% word1" } for (var j = 0; j < st.rulesB.length; j++) { var b = st.rulesB[j]; if (!b.enabled || !clauseBValid(b)) continue; var n = scores && typeof scores["n" + j] === "number" ? scores["n" + j] : null; if (n === null) return null; results.push(b.above ? n > 0.5 : n < 0.5); // strict: exactly 0.5 fails both ways } for (var k = 0; k < st.rulesE.length; k++) { var e = st.rulesE[k]; if (!e.enabled || !clauseEValid(e)) continue; var s = scores && typeof scores["e" + k] === "number" ? scores["e" + k] : null; if (s === null) return null; results.push(s < effectiveCutoff(e)); // below the hide-line → this clause doesn't hide } if (results.length === 0) return null; var pass = st.combine === "or" ? results.some(function (x) { return x; }) : results.every(function (x) { return x; }); return pass ? "show" : "hide"; } // Jev-shaped convenience wrapper — kept while callers of the raw answer // shape (the userscript) still exist. function decideFromAnswers(st, answers) { return decideFromScores(st, jevToScores(st, answers)); } // Round a plain score map for storage/telemetry (cache details, sidebar). function roundScores(scores) { var d = {}; Object.keys(scores || {}).forEach(function (k) { if (typeof scores[k] === "number" && isFinite(scores[k])) { d[k] = Math.round(scores[k] * 1000) / 1000; } }); return d; } // Score details behind a verdict — telemetry for tuning and for explaining // near-threshold behavior. Rounded view of jevToScores: p = P(word1) for // clause A, n = Noul for clause B. function answerDetails(st, answers) { return roundScores(jevToScores(st, answers)); } /* ---------------- model runtime interface ---------------- * * SUX-2: the in-browser model runtime implements this interface and hands * it to SuxShared.registerEmbedder. sux only ever touches these members, so * any model that can turn post text into vectors plugs in the same way: * * { * id: "embeddinggemma-300m-q4@", // identity — goes into cache keys * dims: 768, // vector width * ready(): Promise, // download + init, reports progress * embed(texts: string[], kind: "post" | "query"): Promise * } * * `kind` hides the model's prompt prefixes from sux (EmbeddingGemma uses * "task: classification | query: "); the runtime owns those strings. One * call embeds many texts. Laya does not fit this interface — the bench says * it isn't worth keeping anyway. */ var registeredEmbedder = null; // Returns null when e satisfies the interface, else a short reason. Pure // shape check only; ready()/embed() behavior is the runtime's business. function checkEmbedder(e) { if (!e || typeof e !== "object") return "embedder must be an object"; if (typeof e.id !== "string" || !e.id) return "id must be a non-empty string"; if (typeof e.dims !== "number" || e.dims <= 0 || Math.floor(e.dims) !== e.dims) { return "dims must be a positive integer"; } if (typeof e.ready !== "function") return "ready() missing"; if (typeof e.embed !== "function") return "embed() missing"; return null; } function registerEmbedder(e) { var problem = checkEmbedder(e); if (problem) throw new Error("bad embedder: " + problem); registeredEmbedder = e; } function getEmbedder() { return registeredEmbedder; } /* ---------------- vector math (pure; used by local scoring) ---------------- */ // Cosine similarity; 0 when either side is a zero vector (a missing // embedding must not produce NaN downstream). function cosine(a, b) { var dot = 0, na = 0, nb = 0; var n = Math.min(a ? a.length : 0, b ? b.length : 0); for (var i = 0; i < n; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } if (!na || !nb) return 0; return dot / (Math.sqrt(na) * Math.sqrt(nb)); } // Component-wise mean of a non-empty array of equal-length vectors. function meanVector(vecs) { if (!vecs.length) return null; var out = new Array(vecs[0].length); for (var d = 0; d < out.length; d++) { var s = 0; for (var i = 0; i < vecs.length; i++) s += vecs[i][d]; out[d] = s / vecs.length; } return out; } /* ---------------- SUX-4: example-rule scoring (pure) ---------------- */ // "Hide posts like these": how much more the post looks like the examples // than like the user's feed overall. Positive → closer to the examples than // to the feed average. Bench: AUROC 0.97 politics / 0.96 angry with 3–5 // examples and the pool as the "not this" side. // // SUX-6: counter-examples (posts the user peeked and rejected) sharpen the // "not this" side — they join the feed average in the contrast, they never // replace it. Near misses on top of the feed gave the best fuzzy-rule // results in the bench (self-promotion 0.83, funny 0.75); without the feed // average they're worse than nothing. function scoreExampleRule(postEmb, exampleEmbs, feedAvgEmb, counterEmbs) { var likeExamples = cosine(postEmb, meanVector(exampleEmbs)); var notThis = []; if (feedAvgEmb) notThis.push(feedAvgEmb); if (counterEmbs) { for (var i = 0; i < counterEmbs.length; i++) notThis.push(counterEmbs[i]); } var likeNotThis = notThis.length ? cosine(postEmb, meanVector(notThis)) : 0; return likeExamples - likeNotThis; } /* ---------------- SUX-5: automatic cutoff + strictness ---------------- * * Natural cutoffs are too cautious: with 5 politics examples, flagged * posts are right 89% of the time but only 65% of politics posts get * flagged. So the cutoff comes from the examples themselves: score each * against the mean of the others (leave-one-out) and take the lowest — * every example just barely hides — then back off by MARGIN so near-miss * posts still hide. The strictness slider shifts the cutoff ±MARGIN around * that default (0.5 = the auto value). */ var CUTOFF_MARGIN = 0.05; // How far the strictness slider walks from the auto cutoff. The bench sets // this: the PR frontier for 5-example rules spans ≈ looMin−0.05 … looMin+0.10 // (P 0.37→0.84, R 0.98→0.68 on politics), so ±0.15 covers the useful range; // ±margin would spend the whole slider on a sliver of it. var STRICTNESS_SPAN = 0.15; // Lowest leave-one-out score minus margin; null with fewer than 2 examples // (leave-one-out of one example is cos(x, x) = 1, a degenerate cutoff). function looCutoff(exampleEmbs, feedAvgEmb, margin) { if (!exampleEmbs || exampleEmbs.length < 2) return null; var m = typeof margin === "number" ? margin : CUTOFF_MARGIN; var lowest = Infinity; for (var i = 0; i < exampleEmbs.length; i++) { var others = []; for (var j = 0; j < exampleEmbs.length; j++) { if (j !== i) others.push(exampleEmbs[j]); } var s = scoreExampleRule(exampleEmbs[i], others, feedAvgEmb); if (s < lowest) lowest = s; } return lowest - m; } // The cutoff decideFromScores actually uses: the stored auto-cutoff shifted // by the slider. Higher strictness → higher cutoff → fewer posts hidden. // strictness 0.5 = the auto value; 0/1 walk ±STRICTNESS_SPAN. function effectiveCutoff(c) { var base = typeof c.cutoff === "number" && isFinite(c.cutoff) ? c.cutoff : 0; var strictness = typeof c.strictness === "number" ? c.strictness : 0.5; return base + (strictness - 0.5) * 2 * STRICTNESS_SPAN; } // SUX-6: which clause hid this post? The first enabled valid clause whose // score crosses its hide-line, E before B before A — example rules first // because they're the ones corrections can act on. Returns {type, index, // key, score, name} or null when nothing hides (peek placeholders only // render on hide, so null is unusual but safe). function hiddenByClause(st, scores) { for (var k = 0; k < st.rulesE.length; k++) { var e = st.rulesE[k]; if (!e.enabled || !clauseEValid(e)) continue; var s = scores && scores["e" + k]; if (typeof s === "number" && s >= effectiveCutoff(e)) { return { type: "E", index: k, key: "e" + k, score: s, name: e.name || "hidden examples" }; } } for (var j = 0; j < st.rulesB.length; j++) { var b = st.rulesB[j]; if (!b.enabled || !clauseBValid(b)) continue; var n = scores && scores["n" + j]; if (typeof n === "number" && (b.above ? n <= 0.5 : n >= 0.5)) { return { type: "B", index: j, key: "n" + j, score: n, name: b.word }; } } for (var i = 0; i < st.rulesA.length; i++) { var a = st.rulesA[i]; if (!a.enabled || !clauseAValid(a)) continue; var p = scores && scores["p" + i]; if (typeof p === "number" && p < 0.5) { return { type: "A", index: i, key: "p" + i, score: p, name: a.word1 + " vs " + a.word2 }; } } return null; } /* ---------------- SUX-8: is this rule too fuzzy for the local model? ---------------- * * Tone and intent rules (funny, self-promotion) land ~0.15 AUROC below * topic rules no matter how many examples they get; the local model can't * see them. The heuristic is the mean leave-one-out score — how well the * examples recognize each other over the feed. Bench validation: medians * separate crisply (politics -0.027, angry +0.062 vs self-promotion * -0.053, funny -0.059, threshold -0.04) and match the AUROC ranking. * Mean pairwise similarity was tested and rejected (self-promotion * examples cluster tightly — they just don't separate from the feed). * Draw-to-draw tails overlap, so the badge is a warning, not a verdict. */ var FUZZY_LMEAN_THRESHOLD = -0.04; // Mean leave-one-out score, or null with fewer than 2 examples. function ruleFuzziness(exampleEmbs, feedAvgEmb) { if (!exampleEmbs || exampleEmbs.length < 2) return null; var sum = 0; for (var i = 0; i < exampleEmbs.length; i++) { var others = []; for (var j = 0; j < exampleEmbs.length; j++) { if (j !== i) others.push(exampleEmbs[j]); } sum += scoreExampleRule(exampleEmbs[i], others, feedAvgEmb); } return sum / exampleEmbs.length; } // true = warn: this rule needs Jev for good results. function needsJevForQuality(exampleEmbs, feedAvgEmb) { var f = ruleFuzziness(exampleEmbs, feedAvgEmb); return f === null ? false : f < FUZZY_LMEAN_THRESHOLD; } /* ---------------- SUX-9: word rules trained from Jev's answers ---------------- * * The bench check (results_train_from_jev.md): a separator trained on * Jev's confident answers matches one trained on Claude's labels for * topic rules (politics 0.981 vs 0.981, angry 0.965 vs 0.964) and is ~89% * local after 50 Jev calls; funny lags (0.68 vs 0.73) — the SUX-8 fuzzy * class, which goes to Jev anyway. TRAIN_T sets how fast the trained * pseudo-probability saturates; 0.05 puts ~90% of scores outside the * SUX-7 band once ~30 posts are trained. */ var TRAIN_T = 0.05; function trainedSidesReady(t) { return !!t && t.hide.length >= 1 && t.keep.length >= 1 && (t.hide.length + t.keep.length) >= 3; } // Probability the rule hides the post, from the trained separator. function trainedHideProb(postEmb, trained, feedAvgEmb) { return 1 / (1 + Math.exp(-scoreExampleRule(postEmb, trained.hide, feedAvgEmb, trained.keep) / TRAIN_T)); } // Which side of the separator a confident Jev answer lands on: "keep" (the // rule would show the post), "hide" (it would hide), or null (not confident // enough to train on). Direction-aware for rule B. function trainedSide(kind, clause, score) { if (typeof score !== "number") return null; if (kind === "A") { if (score >= 0.8) return "keep"; // confidently word1 → shown if (score <= 0.2) return "hide"; // confidently word2 → hidden return null; } if (score >= 0.8) return clause.above ? "keep" : "hide"; // confidently the word if (score <= 0.2) return clause.above ? "hide" : "keep"; // confidently not return null; } // trainedHideProb back onto the clause's native probability scale: p = // P(word1), n = P(word). Hide prob is P(hidden); the native score is its // complement for A and for B-with-above (shown when the word matches). function trainedToNative(hideProb, kind, clause) { if (kind === "A") return 1 - hideProb; return clause.above ? 1 - hideProb : hideProb; } /* ---------------- SUX-3: embedding cache keys, feed average ---------------- */ // Keyed by model id + post content, deliberately separate from cacheKey: // rule changes must not redo model work. Same FNV-1a as cacheKey; a shared // embedding on collision is harmless for the same reason. function embCacheKey(modelId, text, alt) { return hash(modelId + "\n" + (text || "") + "\n" + (alt || "")); } // One EMA step: prev null → copy of next; otherwise out = (1-a)·prev + a·next. // Vectors are unnormalized embeddings, so the average keeps drifting toward // whatever the feed currently looks like; cosine scoring is scale-insensitive. function emaStep(prev, next, alpha) { if (!prev) return next.slice(); var out = new Array(next.length); for (var d = 0; d < next.length; d++) out[d] = (1 - alpha) * prev[d] + alpha * next[d]; return out; } /* ---------------- SUX-10: zero-shot wording for word rules ---------------- * * Jev's criteria read "The post is mainly about angry or clearly expresses * angry" — ungrammatical for adjectives, and it drags the embedder too * (topic accuracy 0.40). These short natural descriptions are embedding-side * only; Jev keeps its own wording. Bench (results_wording*.md, q4): the * about/else pair embedded with kind "query" beats the old criteria text on * all five rules — politics 0.971 vs 0.945, angry 0.922 vs 0.916, * self-promotion 0.713 vs 0.599, funny 0.688 vs 0.602, topic 0.46 vs 0.40. */ function zeroShotDescriptions(word) { var w = String(word || "").trim(); return { pos: "A post about " + w + ".", neg: "A post about something else.", }; } // Bench temperature: noul = sigmoid((cos_pos − cos_neg)/T), T = 0.02. Rule A // reuses it as P(word1) = sigmoid((cos(w1) − cos(w2))/T). var ZS_T = 0.02; function zeroShotProb(cosFor, cosAgainst) { return 1 / (1 + Math.exp(-(cosFor - cosAgainst) / ZS_T)); } /* ---------------- SUX-7: escalate unsure scores to Jev ---------------- * * A rule decides locally when its score sits at least ESC_MARGIN clear of * its boundary (0.5 for word rules, the effective cutoff for example * rules); inside the band the local model is guessing, so the clause goes * to Jev instead. Missing local score → Jev (that's the pre-escalation * behavior for word rules without a runtime). * * ESC_MARGIN is bench-set (bench/results_escalation.md): 0.2 spends the * escalation budget best — politics agrees with Jev on ~95% of posts at * ~13% escalated; 0.1 halves the Jev spend for ~2-4pp less agreement. The * ticket's "within 0.02 of Jev-only" guess is optimistic: zero-shot scores * are over-confident (T=0.02 saturates fast), so the band catches few * posts, and tone/intent rules (funny, self-promotion) stay ~0.15 AUROC * below Jev at any sane escalation rate — that's the SUX-8 fuzzy class. */ var ESC_MARGIN = 0.2; function needsEscalation(score, boundary, margin) { var m = (typeof margin === "number" ? margin : ESC_MARGIN) - 1e-9; return Math.abs(score - boundary) < m; } // Partition enabled valid clauses by backend. scores maps p/n/e to // local scores; a clause with a score clear of its boundary is local, one // inside the band (or unscored) needs Jev. Returns {local, jev} of clause // ids ("A0", "B1", "E2" — array indexes into rulesA/rulesB/rulesE). function escalationPlan(st, scores, margin) { var plan = { local: [], jev: [] }; st.rulesA.forEach(function (c, i) { if (!c.enabled || !clauseAValid(c)) return; var p = scores && typeof scores["p" + i] === "number" ? scores["p" + i] : null; (p !== null && !needsEscalation(p, 0.5, margin) ? plan.local : plan.jev).push("A" + i); }); st.rulesB.forEach(function (c, j) { if (!c.enabled || !clauseBValid(c)) return; var n = scores && typeof scores["n" + j] === "number" ? scores["n" + j] : null; (n !== null && !needsEscalation(n, 0.5, margin) ? plan.local : plan.jev).push("B" + j); }); st.rulesE.forEach(function (c, k) { if (!c.enabled || !clauseEValid(c)) return; var s = scores && typeof scores["e" + k] === "number" ? scores["e" + k] : null; (s !== null && !needsEscalation(s, effectiveCutoff(c), margin) ? plan.local : plan.jev).push("E" + k); }); return plan; } // Map an escalated Jev probability onto the local score scale so it // compares correctly against an example rule's cutoff: p=1 lands a full // margin on the hide side, p=0 on the show side, p=0.5 at the cutoff. function escalatedToLocalScale(p, cutoff, margin) { var m = typeof margin === "number" ? margin : ESC_MARGIN; return cutoff + (2 * p - 1) * m; } // int8 storage (768 floats ≈ 3 KB → ≈ 0.8 KB per post at the 2,000-entry // cap): per-vector max-abs scaling, values snapped to [-127, 127]. SUX-12 // flags the loss if it ever matters; cosine barely notices it. function quantEmb(emb) { var max = 0; for (var i = 0; i < emb.length; i++) { var a = Math.abs(emb[i]); if (a > max) max = a; } if (!max) return { s: 1, q: emb.map(function () { return 0; }) }; var s = max / 127; return { s: s, q: emb.map(function (x) { return Math.round(x / s); }) }; } function dequantEmb(qe) { var s = qe.s || 1; return qe.q.map(function (x) { return x * s; }); } var SuxShared = { TEXT_CAP: TEXT_CAP, WORD_CAP: WORD_CAP, defaults: defaults, normalizeSettings: normalizeSettings, LOCAL_MODEL: LOCAL_MODEL, modeAllowsJev: modeAllowsJev, modeAllowsLocal: modeAllowsLocal, reportModelProgress: reportModelProgress, getModelProgress: getModelProgress, clauseAValid: clauseAValid, clauseBValid: clauseBValid, clauseEValid: clauseEValid, isActive: isActive, hash: hash, cacheKey: cacheKey, isJudgeable: isJudgeable, buildQuestions: buildQuestions, buildRequest: buildRequest, buildBatchRequest: buildBatchRequest, splitBatchAnswers: splitBatchAnswers, clauseScoreKeys: clauseScoreKeys, decideFromAnswers: decideFromAnswers, jevToScores: jevToScores, decideFromScores: decideFromScores, answerDetails: answerDetails, roundScores: roundScores, checkEmbedder: checkEmbedder, registerEmbedder: registerEmbedder, getEmbedder: getEmbedder, cosine: cosine, meanVector: meanVector, scoreExampleRule: scoreExampleRule, zeroShotDescriptions: zeroShotDescriptions, zeroShotProb: zeroShotProb, ZS_T: ZS_T, ESC_MARGIN: ESC_MARGIN, needsEscalation: needsEscalation, escalationPlan: escalationPlan, escalatedToLocalScale: escalatedToLocalScale, hiddenByClause: hiddenByClause, ruleFuzziness: ruleFuzziness, needsJevForQuality: needsJevForQuality, FUZZY_LMEAN_THRESHOLD: FUZZY_LMEAN_THRESHOLD, TRAIN_T: TRAIN_T, trainedSidesReady: trainedSidesReady, trainedHideProb: trainedHideProb, trainedSide: trainedSide, trainedToNative: trainedToNative, looCutoff: looCutoff, effectiveCutoff: effectiveCutoff, CUTOFF_MARGIN: CUTOFF_MARGIN, STRICTNESS_SPAN: STRICTNESS_SPAN, embCacheKey: embCacheKey, emaStep: emaStep, quantEmb: quantEmb, dequantEmb: dequantEmb, }; global.SuxShared = SuxShared; if (typeof module !== "undefined" && module.exports) { module.exports = SuxShared; } })(typeof globalThis !== "undefined" ? globalThis : this);