/** * Client-side Sludge: inference, decoding, provision mapping and remediation, in the browser. * * Mirrors sludge/model.py (SludgeDetector._decode), sludge/provisions.py (ProvisionMapper.map) * and the measured-value half of sludge/remediate.py. The feature vectors come from * featurizer.js, which is checked against the Python implementation by * scripts/check_web_parity.py. * * Nothing here decides legality. Every finding is "this provision may be implicated, here is * the element that is the factual basis" — the same contract as the CLI. */ import { Featurizer, contrastRatio, tokenize } from "./featurizer.js"; const ORT_CDN = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.19.2/dist/"; export class SludgeWeb { constructor() { this.ready = false; } async load(base = "assets", onProgress = () => {}) { // onProgress(message, fraction|null) onProgress("loading metadata"); const j = async (f) => (await fetch(`${base}/${f}`)).json(); const [lex, meta, vocab, provisions, examples] = await Promise.all([ j("lexicons.json"), j("meta.json"), j("vocab.json"), j("provisions.json"), j("examples.json"), ]); this.meta = meta; this.provisions = provisions; this.examples = examples; this.patterns = new Map(meta.patterns.map((p) => [p.id, p])); this.fz = new Featurizer(lex, meta, vocab); onProgress("loading runtime"); const ort = globalThis.ort; if (!ort) throw new Error("onnxruntime-web did not load"); ort.env.wasm.wasmPaths = ORT_CDN; ort.env.wasm.numThreads = 1; // The graph ships in fp32. int8 was measured to flip about a quarter of this model's // screen-level decisions and fp16 is rejected by onnxruntime-web's WASM build, so the // honest choice is the larger download — reported here rather than hidden behind a spinner. const res = await fetch(`${base}/model.onnx`); const total = Number(res.headers.get("content-length")) || 0; const chunks = []; let got = 0; const reader = res.body?.getReader(); if (reader) { for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); got += value.length; onProgress(total ? `downloading model — ${(got / 1e6).toFixed(1)} of ${(total / 1e6).toFixed(0)} MB` : `downloading model — ${(got / 1e6).toFixed(1)} MB`, total ? got / total : null); } } const bytes = new Uint8Array(got); let off = 0; for (const c of chunks) { bytes.set(c, off); off += c.length; } const buf = reader ? bytes.buffer : await res.arrayBuffer(); onProgress("starting session"); this.session = await ort.InferenceSession.create(buf, { executionProviders: ["wasm"], graphOptimizationLevel: "all", }); this.ort = ort; this.ready = true; return this; } /** * Run the model on one tree. * * The graph has a fixed node axis (`max_nodes`), so inputs are padded and masked here. That * is verified output-preserving: padded positions are removed from attention by the key * padding mask, and the screen-level pooling below uses only the real nodes, so the result * matches an unpadded PyTorch run to float noise. */ async predict(rawTree) { const f = this.fz.featurize(rawTree); const { Tensor } = this.ort; const N = this.meta.model.max_nodes, T = f.T, F = f.F; const n = Math.min(f.n, N); if (f.n > N) { console.warn(`tree has ${f.n} elements; the graph takes ${N}, so the tail is ignored`); } const pad = (src, per, Ctor) => { const dst = new Ctor(N * per); dst.set(src.subarray(0, n * per)); return dst; }; const feeds = { tokens: new Tensor("int64", pad(f.tokens, T, BigInt64Array), [1, N, T]), token_mask: new Tensor("float32", pad(f.tokenMask, T, Float32Array), [1, N, T]), roles: new Tensor("int64", pad(f.roles, 1, BigInt64Array), [1, N]), numeric: new Tensor("float32", pad(f.numeric, F, Float32Array), [1, N, F]), screen: new Tensor("int64", pad(f.screen, 1, BigInt64Array), [1, N]), node_mask: new Tensor("float32", Float32Array.from({ length: N }, (_, i) => (i < n ? 1 : 0)), [1, N]), }; const t0 = performance.now(); const out = await this.session.run(feeds); const ms = performance.now() - t0; const P = this.meta.pattern_ids.length; const nodeLogits = out.node_logits.data; // (1, N, P) - only the first n rows are real const flowLogits = out.flow_logits.data; // (1, P) // screen_logits = logsumexp over nodes - log(n) + flow, exactly as PatternDetectorNet does const screenProb = new Float32Array(P); for (let k = 0; k < P; k++) { let mx = -Infinity; for (let i = 0; i < n; i++) mx = Math.max(mx, nodeLogits[i * P + k]); let sum = 0; for (let i = 0; i < n; i++) sum += Math.exp(nodeLogits[i * P + k] - mx); const lse = mx + Math.log(sum) - Math.log(Math.max(1, n)); screenProb[k] = 1 / (1 + Math.exp(-(lse + flowLogits[k]))); } const nodeProb = new Float32Array(n * P); for (let i = 0; i < n * P; i++) nodeProb[i] = 1 / (1 + Math.exp(-nodeLogits[i])); return { f, screenProb, nodeProb, P, n, ms }; } /** Mirrors SludgeDetector._decode: class above threshold, evidence from the per-node head. */ decode(pred, minScore = null) { const { f, screenProb, nodeProb, P, n } = pred; const th = this.meta.thresholds; const dets = []; for (let k = 0; k < P; k++) { const pid = this.meta.pattern_ids[k]; const thr = minScore === null ? (th.screen[pid] ?? 0.5) : minScore; const score = screenProb[k]; if (score < thr) continue; const idx = Array.from({ length: n }, (_, i) => i) .sort((a, b) => nodeProb[b * P + k] - nodeProb[a * P + k]) .slice(0, th.top_k_evidence) .filter((i) => nodeProb[i * P + k] >= th.node); const picked = idx.length ? idx : [Array.from({ length: n }, (_, i) => i) .reduce((a, b) => (nodeProb[b * P + k] > nodeProb[a * P + k] ? b : a), 0)]; dets.push({ pattern_id: pid, score, evidence: picked.map((i) => f.nodeIds[i]), scope: this.patterns.get(pid)?.scope || "element", screen: f.nodes[picked[0]]?.screen ?? 0, }); } dets.sort((a, b) => b.score - a.score); return dets; } /** Mirrors ProvisionMapper.map. Merges by provision, carries evidence through unchanged. */ mapProvisions(dets, jurisdiction) { const table = this.provisions[jurisdiction]; if (!table) return []; const acc = new Map(); for (const d of dets) { for (const prov of table.provisions) { const trig = prov.triggers[d.pattern_id]; if (!trig) continue; if (!acc.has(prov.key)) { acc.set(prov.key, { prov, elements: new Map(), evidence: [], patterns: [], rationale: [], score: 0 }); } const slot = acc.get(prov.key); for (const eid of trig.elements) { const el = prov.elements.find((e) => e.id === eid); if (el) slot.elements.set(eid, el.requirement); } for (const nid of d.evidence) if (!slot.evidence.includes(nid)) slot.evidence.push(nid); if (!slot.patterns.includes(d.pattern_id)) { slot.patterns.push(d.pattern_id); slot.rationale.push(`${d.pattern_id}: ${trig.rationale || this.patterns.get(d.pattern_id)?.definition || ""}`); } slot.score = Math.max(slot.score, d.score); } } return [...acc.values()] .map((s) => ({ provision_key: s.prov.key, instrument: s.prov.instrument, citation: s.prov.citation, title: s.prov.title, status: s.prov.status, scope_note: s.prov.scope_note, status_note: s.prov.status_note, elements: [...s.elements].map(([id, requirement]) => ({ id, requirement })), evidence: s.evidence, patterns: s.patterns, rationale: s.rationale, score: s.score, determination: "may_be_implicated", })) .sort((a, b) => b.score - a.score || a.provision_key.localeCompare(b.provision_key)); } /** * The measured half of RemediationSuggester: advice whose target values are read off this * screen. Classes whose fix needs new copy are reported as advisory, never as a mechanical * change this page could apply for you. */ remediate(tree, dets) { const byId = new Map(tree.nodes.map((n) => [n.node_id, n])); const fz = this.fz; const label = (n) => fz.label(n); const siblings = (n) => tree.nodes.filter( (o) => o.parent_id && o.parent_id === n.parent_id && o.node_id !== n.node_id); const peerOf = (n) => { const s = siblings(n).filter((x) => fz.isInteractive(x)); const pool = s.length ? s : tree.nodes.filter( (x) => x.screen === n.screen && fz.isInteractive(x) && x.node_id !== n.node_id); return pool.length ? pool.reduce((a, b) => (fz.area(b) > fz.area(a) ? b : a)) : null; }; const contrastColour = (fg, bg, target) => { if (contrastRatio(fg, bg) >= target) return fg; const bgLight = (bg[0] + bg[1] + bg[2]) / 765 > 0.5; const best = bgLight ? [0, 0, 0] : [255, 255, 255]; for (let t = 1; t <= 20; t++) { const k = t / 20; const cand = fg.map((v, i) => Math.round(v + (best[i] - v) * k)); if (contrastRatio(cand, bg) >= target) return cand; } return best; }; const out = []; for (const d of dets) { const n = d.evidence.map((e) => byId.get(e)).find(Boolean); if (!n) continue; const pid = d.pattern_id; let s = null; if (pid === "false_hierarchy") { const p = peerOf(n); if (p) s = { action: "equalise_control_prominence", mechanical: true, text: `Render “${label(n)}” at ${p.bbox[2].toFixed(0)}×${p.bbox[3].toFixed(0)} px with ` + `${p.font_size.toFixed(0)} px type, matching “${label(p)}” (currently ` + `${n.bbox[2].toFixed(0)}×${n.bbox[3].toFixed(0)} px, ${n.font_size.toFixed(0)} px).`, from: [p.node_id] }; } else if (pid === "visual_interference") { const t = contrastColour(n.color, n.background, 4.5); s = { action: "restore_text_contrast", mechanical: true, text: `Raise “${label(n).slice(0, 44)}” to at least 4.5:1 against its background ` + `(currently ${contrastRatio(n.color, n.background).toFixed(2)}:1). ` + `rgb(${t.join(", ")}) reaches ` + `${contrastRatio(t, n.background).toFixed(2)}:1.` }; } else if (pid === "hidden_subscription") { const prices = tree.nodes.filter( (x) => x.screen === n.screen && fz.test("PRICE_RE", label(x))); const p = prices.length ? prices.reduce((a, b) => (b.font_size > a.font_size ? b : a)) : null; if (p) s = { action: "match_recurrence_to_price_prominence", mechanical: true, text: `Render the renewal terms at ${p.font_size.toFixed(0)} px weight ` + `${p.font_weight}, matching the headline price “${label(p)}” (currently ` + `${n.font_size.toFixed(0)} px weight ${n.font_weight}).`, from: [p.node_id] }; } else if (pid === "preselection" || pid === "pressured_selling") { s = { action: "clear_default_selection", mechanical: true, text: `Ship “${label(n).slice(0, 48)}” unselected, so the user's own act is what sets it.` }; } else if (pid === "confirmshaming") { s = { action: "neutralise_decline_wording", mechanical: true, text: `Replace “${label(n)}” with a neutral label — “No thanks” or “Not now” — ` + `stating the choice without judging it.` }; } else if (pid === "hidden_costs") { s = { action: "disclose_all_price_components_up_front", mechanical: true, text: `Show “${label(n)}” on the first screen where a price appears (step 0), not at ` + `step ${n.screen}, and include it in the displayed total.` }; } else if (pid === "dead_end") { s = { action: "make_the_path_resolve", mechanical: true, text: `“${label(n)}” promises a destination but resolves nowhere. Point it at a screen ` + `that contains the control it names.` }; } else if (pid === "hard_to_cancel") { const signup = Number(tree.meta?.signup_steps || 2); s = { action: "match_cancellation_effort_to_signup", mechanical: false, text: `Cancellation takes ${Math.max(1, n.step_cost || 1)} steps against ${signup} to ` + `sign up. Reduce it to at most ${signup}, in the channel the user signed up in.` }; } else if (pid === "forced_enrollment") { const p = peerOf(n); s = { action: "restore_an_equal_decline_path", mechanical: false, text: `Offer a working decline control beside “${label(n)}”` + (p ? `, at ${p.bbox[2].toFixed(0)}×${p.bbox[3].toFixed(0)} px` : "") + `, that completes the user's original task.` }; } else { const advice = { countdown_timer: "Replace the timer with the date and time the offer actually ends, and make it survive a reload. If there is no such deadline, remove it.", limited_time_message: "State the date the offer ends, or remove the claim.", low_stock_message: "State the inventory figure and where it comes from, or remove the claim.", high_demand_message: "State the figure and the window it was measured over, or remove the claim.", activity_message: "Derive the message from real, time-bounded events, or remove it.", testimonials_uncertain_origin: "Show who wrote the review and whether the purchase was verified, next to the quote.", trick_question: "Rewrite as one positive statement of what selecting it does; remove the double negation.", disguised_ad: "Label the promotional block “Sponsored” inside the ad container, at the ad body's own size and contrast.", sneak_into_basket: "Remove the line from the basket unless the user adds it; if offered, offer it unselected.", bait_and_switch: "Either point the control at what its label says, or relabel it to name what its target does.", hidden_information: "Show the text at the same level as the control it qualifies, not inside a collapsed container.", price_comparison_prevention: "Show a per-unit price on every option in the group, on one screen.", intermediate_currency: "Show the real-currency price beside the virtual one at the point of purchase.", privacy_maze: "Present all privacy controls on one screen reachable in a single step.", privacy_zuckering: "Ship every sharing option unselected and put the granular controls beside “Accept all”, at equal prominence.", forced_registration: "Offer “Continue as guest” beside account creation for a task that does not require an account.", nagging: "Stop re-showing the prompt after a decline; if it must reappear, give it a dismiss control and a long interval.", }[pid]; if (advice) s = { action: "advisory", mechanical: false, text: advice }; } if (s) out.push({ pattern_id: pid, node_id: n.node_id, ...s }); } return out; } } /** DOM-based port of uitree.tree_from_html, for pasted markup. */ export function treeFromHtml(html) { const ROLE_BY_TAG = { a: "link", button: "button", input: "textbox", select: "combobox", textarea: "textbox", h1: "heading", h2: "heading", h3: "heading", h4: "heading", h5: "heading", h6: "heading", img: "image", label: "text", p: "text", span: "text", li: "listitem", ul: "list", ol: "list", form: "form", dialog: "dialog", table: "table", strong: "text", em: "text", small: "text", }; const INPUT_ROLE = { checkbox: "checkbox", radio: "radio", submit: "button", button: "button", range: "slider" }; const NAMED = { black: [0,0,0], white: [255,255,255], red: [255,0,0], green: [0,128,0], blue: [0,0,255], gray: [128,128,128], grey: [128,128,128], silver: [192,192,192], lightgray: [211,211,211], lightgrey: [211,211,211], darkgray: [169,169,169], darkgrey: [169,169,169], transparent: [255,255,255], orange: [255,165,0], navy: [0,0,128], teal: [0,128,128], purple: [128,0,128], maroon: [128,0,0] }; function parseColor(v, fallback) { if (!v) return fallback; const s = String(v).trim().toLowerCase(); if (NAMED[s]) return NAMED[s]; let m = /^#([0-9a-f]{3})$/.exec(s); if (m) return [...m[1]].map((c) => parseInt(c + c, 16)); m = /^#([0-9a-f]{6})$/.exec(s); if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16)); m = /^rgba?\(([^)]*)\)$/.exec(s); if (m) { const parts = m[1].replace(/\//g, ",").split(",").map((x) => x.trim()).filter(Boolean); const pct = parts.slice(0, 3).some((p) => p.endsWith("%")); const vals = parts.slice(0, 3).map((p) => parseFloat(p)); if (vals.every(Number.isFinite)) { return pct ? vals.map((x) => Math.round(x * 255 / 100)) : vals.map(Math.round); } } return fallback; } const decls = (style) => Object.fromEntries( (style || "").split(";").filter((d) => d.includes(":")) .map((d) => { const i = d.indexOf(":"); return [d.slice(0, i).trim().toLowerCase(), d.slice(i + 1).trim()]; })); function fontPx(v, inherited) { if (!v) return inherited; const m = /([\d.]+)\s*(px|pt|rem|em|%)?/.exec(v.trim()); if (!m) return inherited; const n = parseFloat(m[1]); return { px: n, pt: n * 4 / 3, rem: n * 16, em: n * inherited, "%": inherited * n / 100 }[m[2] || "px"]; } const doc = new DOMParser().parseFromString(html, "text/html"); const nodes = []; let counter = 0, y = 0; function walk(el, parentId, depth, inh) { if (!el || el.nodeType !== 1) return; const tag = el.tagName.toLowerCase(); if (["script", "style", "head", "meta", "title"].includes(tag)) return; const d = decls(el.getAttribute("style")); const fontSize = fontPx(d["font-size"], inh.font_size); const color = parseColor(d.color, inh.color); const background = parseColor(d["background-color"] || d.background, inh.background); const wRaw = d["font-weight"] || ""; const weight = ["bold", "bolder"].includes(wRaw) ? 700 : (/^\d+$/.test(wRaw) ? parseInt(wRaw, 10) : (["strong", "b", "h1", "h2", "h3"].includes(tag) ? 700 : inh.font_weight)); let role = ROLE_BY_TAG[tag] || "generic"; if (tag === "input") role = INPUT_ROLE[(el.getAttribute("type") || "text").toLowerCase()] || "textbox"; role = (el.getAttribute("role") || role).toLowerCase(); counter += 1; const nid = el.id || `n${counter}`; const ownText = [...el.childNodes].filter((c) => c.nodeType === 3) .map((c) => c.textContent).join("").replace(/\s+/g, " ").trim(); let width = d.width && d.width.endsWith("px") ? parseFloat(d.width) : 0; let height = d.height && d.height.endsWith("px") ? parseFloat(d.height) : 0; if (!width) width = Math.max(40, Math.min(360, ownText.length * fontSize * 0.55 + 24)); if (!height) height = fontSize * (["button", "link"].includes(role) ? 2.6 : 1.4); const pad = /([\d.]+)px/.exec(d.padding || ""); if (pad) height += 2 * parseFloat(pad[1]); nodes.push({ node_id: nid, role, text: ownText, aria_label: el.getAttribute("aria-label") || "", bbox: [0, y, width, height], font_size: fontSize, font_weight: weight, color, background, depth, screen: 0, parent_id: parentId, children: [], default_selected: el.hasAttribute("checked") || el.hasAttribute("selected") || el.getAttribute("aria-checked") === "true", disabled: el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true", hidden: el.hasAttribute("hidden") || d.display === "none" || d.visibility === "hidden", aria_hidden: el.getAttribute("aria-hidden") === "true", focusable: ["button", "link", "checkbox", "radio", "textbox", "combobox"].includes(role), live_region: !!el.getAttribute("aria-live"), // A