/** * Browser port of sludge/features.py — the TreeFeaturizer. * * Why this file exists and why it is dangerous * ------------------------------------------- * The model is trained on an 86-dimensional feature vector computed in Python. If this port * disagrees with that computation by even one column, the browser hands the network inputs it * has never seen and the output is quietly wrong — not obviously broken, just wrong. * * Two things guard against that: * 1. Every lexicon is loaded from `assets/lexicons.json`, which is emitted from the Python * source at export time. No regex is retyped here. * 2. `scripts/check_web_parity.py` runs both implementations over the same trees and requires * the vectors to match exactly. It is part of the test suite, not a one-off. * * Claim substantiated: *low cost*, taken to its conclusion — featurisation is cheap enough to * run client-side, which is what lets the demo be free and keeps a pasted interface on the * machine it was pasted on. */ const ROLE_INTERACTIVE = new Set([ "button", "link", "checkbox", "radio", "switch", "textbox", "combobox", "menuitem", "tab", "slider", "option", ]); const ROLE_CHOICE = new Set(["checkbox", "radio", "switch", "option"]); const WORD_RE = /[a-z0-9']+/g; /** WCAG 2.2 relative luminance of one sRGB channel. */ function srgbChannel(c) { const cs = c / 255.0; return cs <= 0.04045 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4); } export function relativeLuminance([r, g, b]) { return 0.2126 * srgbChannel(r) + 0.7152 * srgbChannel(g) + 0.0722 * srgbChannel(b); } /** WCAG 2.2 contrast ratio in [1, 21]. */ export function contrastRatio(fg, bg) { const l1 = relativeLuminance(fg), l2 = relativeLuminance(bg); const hi = Math.max(l1, l2), lo = Math.min(l1, l2); return (hi + 0.05) / (lo + 0.05); } /** Lowercase word tokenisation. Mirrors features.tokenize exactly. */ export function tokenize(text, maxTokens = 24) { const t = (text || "").normalize("NFKC").toLowerCase(); const out = []; WORD_RE.lastIndex = 0; let m; while ((m = WORD_RE.exec(t)) !== null) { out.push(m[0]); if (out.length >= maxTokens) break; } return out; } /** * Vocabulary with a hashing tail, byte-identical to features.Vocab.encode. * * Python: int.from_bytes(token.encode()[:8].ljust(8, b"\0"), "little") % hash_buckets * That is a little-endian 64-bit integer over the first 8 UTF-8 bytes. JavaScript numbers * cannot hold it exactly, so BigInt is used and only the modulus comes back to Number. */ export class Vocab { constructor(spec) { this.itos = spec.itos; this.hashBuckets = spec.hash_buckets; this.stoi = new Map(this.itos.map((w, i) => [w, i])); this.encoder = new TextEncoder(); } get size() { return this.itos.length + this.hashBuckets; } encode(tokens) { const base = this.itos.length; return tokens.map((t) => { const known = this.stoi.get(t); if (known !== undefined) return known; const bytes = this.encoder.encode(t).slice(0, 8); let v = 0n; for (let i = 0; i < 8; i++) v |= BigInt(bytes[i] ?? 0) << BigInt(8 * i); return base + Number(v % BigInt(this.hashBuckets)); }); } } function safeLog(x) { return Math.log1p(Math.max(0.0, x)); } function ratio(a, b, cap = 8.0) { if (b <= 1e-6) return 0.0; return Math.min(cap, a / b); } /** Median the way Python's `sorted(xs)[len(xs)//2]` does it — upper median, not the mean. */ function pyMedian(xs, fallback) { if (!xs.length) return fallback; const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; } export class Featurizer { /** * @param {object} lexicons assets/lexicons.json — emitted from the Python source * @param {object} meta assets/meta.json — feature order, roles, model config * @param {object} vocabSpec assets/vocab.json */ constructor(lexicons, meta, vocabSpec) { this.meta = meta; this.featureNames = meta.feature_names; this.numFeatures = this.featureNames.length; this.fi = new Map(this.featureNames.map((n, i) => [n, i])); this.roleToId = new Map(meta.roles.map((r, i) => [r, i])); this.vocab = new Vocab(vocabSpec); this.maxTokens = meta.model.max_tokens; this.re = {}; this.reGlobal = {}; for (const [name, spec] of Object.entries(lexicons)) { const flags = spec.ignorecase ? "i" : ""; this.re[name] = new RegExp(spec.source, flags); this.reGlobal[name] = new RegExp(spec.source, flags + "g"); } } test(name, s) { return this.re[name].test(s || ""); } countMatches(name, s) { const rx = this.reGlobal[name]; rx.lastIndex = 0; let n = 0; while (rx.exec(s || "") !== null) { n++; if (rx.lastIndex === 0) break; // zero-width guard } return n; } /** Recover a numeric price from a label. Mirrors features.parse_price. */ parsePrice(text) { const m = this.re.PRICE_VALUE_RE.exec(text || ""); if (!m) return 0.0; const v = parseFloat(m[1].replace(",", ".")); return Number.isFinite(v) ? v : 0.0; } /** Mirrors features._label_action_conflict. */ labelActionConflict(label, action) { if (!action || !label) return false; const act = String(action).toLowerCase().replace(/[^a-z]+/g, " "); const actTerms = new Set(act.split(" ").filter((w) => w.length > 3)); if (!actTerms.size) return false; const lab = new Set(tokenize(label, 32)); const OUTCOMES = ["subscribe", "upgrade", "checkout", "accept", "consent", "purchase", "renew", "enrol", "enroll", "billing", "pay"]; const outcome = OUTCOMES.filter((o) => actTerms.has(o)); if (!outcome.length) return false; return !outcome.some((o) => lab.has(o)); } // -- node helpers, mirroring UINode ------------------------------------------------------- label(n) { return n.aria_label || n.text || ""; } area(n) { const b = n.bbox; return Math.max(0, b[2]) * Math.max(0, b[3]); } contrast(n) { return contrastRatio(n.color, n.background); } isInteractive(n) { return ROLE_INTERACTIVE.has(n.role); } isChoice(n) { return ROLE_CHOICE.has(n.role); } /** Normalise a tree dict (the JSON wire format) into the shape this port expects. */ normalise(tree) { const nodes = tree.nodes.map((n) => ({ node_id: n.node_id, role: n.role || "generic", text: n.text || "", aria_label: n.aria_label || "", bbox: (n.bbox || [0, 0, 0, 0]).map(Number), font_size: n.font_size ?? 14.0, font_weight: n.font_weight ?? 400, color: n.color || [17, 17, 17], background: n.background || [255, 255, 255], depth: n.depth ?? 0, screen: n.screen ?? 0, parent_id: n.parent_id ?? null, children: n.children || [], default_selected: !!n.default_selected, disabled: !!n.disabled, hidden: !!n.hidden, aria_hidden: !!n.aria_hidden, focusable: !!n.focusable, live_region: !!n.live_region, action: n.action ?? null, step_cost: n.step_cost ?? 0, attrs: n.attrs || {}, })); let screens = tree.screens || []; if (!screens.length) { const maxS = nodes.reduce((a, n) => Math.max(a, n.screen), 0); screens = Array.from({ length: maxS + 1 }, (_, i) => ({ index: i, step_from_entry: i })); } return { nodes, screens, meta: tree.meta || {} }; } /** Mirrors TreeFeaturizer._context. */ context(tree) { const byScreen = new Map(); for (const n of tree.nodes) { if (!byScreen.has(n.screen)) byScreen.set(n.screen, []); byScreen.get(n.screen).push(n); } const stats = new Map(); for (const [s, nds] of byScreen) { const texts = nds.filter((x) => x.text && !x.hidden); const areas = nds.map((x) => this.area(x)).filter((a) => a > 0); stats.set(s, { area_med: pyMedian(areas.length ? areas : [1.0], 1.0), font_med: pyMedian(texts.length ? texts.map((x) => x.font_size) : [14.0], 14.0), contrast_med: pyMedian(texts.length ? texts.map((x) => this.contrast(x)) : [21.0], 21.0), depth_med: pyMedian(nds.length ? nds.map((x) => x.depth) : [0], 0), n: nds.length, guest: nds.some((x) => x.attrs.is_guest_path || this.test("GUEST_RE", this.label(x))) ? 1.0 : 0.0, }); } const groupMembers = new Map(); for (const n of tree.nodes) { if (n.parent_id) { if (!groupMembers.has(n.parent_id)) groupMembers.set(n.parent_id, []); groupMembers.get(n.parent_id).push(n); } } const peer = new Map(); for (const members of groupMembers.values()) { const inter = members.filter((m) => this.isInteractive(m)); if (inter.length < 2) continue; // Python's max() keeps the FIRST maximum; reduce with > preserves that. const biggest = inter.reduce((a, b) => (this.area(b) > this.area(a) ? b : a)); for (const m of inter) { if (m === biggest) { const rest = inter.filter((x) => x !== m); peer.set(m.node_id, rest.reduce((a, b) => (this.area(b) > this.area(a) ? b : a))); } else { peer.set(m.node_id, biggest); } } } let signupSteps = Number(tree.meta.signup_steps || 0); if (!signupSteps) { for (const n of tree.nodes) { if (n.attrs.signup_steps) { signupSteps = Number(n.attrs.signup_steps); break; } } } signupSteps = signupSteps || 2.0; let cancelSteps = 0.0, channelChanged = 0.0; const cancelNodes = tree.nodes.filter( (n) => this.isInteractive(n) && this.test("CANCEL_RE", this.label(n))); for (const n of cancelNodes) { cancelSteps = Math.max(cancelSteps, Number(n.step_cost || 0)); if (this.test("OFFLINE_CHANNEL_RE", this.label(n))) channelChanged = 1.0; } const cancelGoalScreens = new Set( tree.screens.filter((s) => this.test("CANCEL_RE", s.goal || "") || this.test("CANCEL_RE", s.name || "")) .map((s) => s.index)); if (cancelNodes.length) cancelSteps = Math.max(cancelSteps, cancelGoalScreens.size); const privScreens = new Set( tree.nodes.filter((n) => this.test("PRIVACY_CONTROL_RE", this.label(n)) && this.isInteractive(n)) .map((n) => n.screen)); const privSpread = privScreens.size ? privScreens.size - 1 : 0.0; const adContainers = new Map(); for (const [pid, kids] of groupMembers) { if (kids.some((k) => this.test("PROMO_RE", this.label(k)))) { const disclosed = kids.some((k) => this.test("SPONSOR_RE", this.label(k))); adContainers.set(pid, disclosed); for (const k of kids) adContainers.set(k.node_id, disclosed); } } const prices = tree.nodes.map((n) => this.parsePrice(this.label(n))); const priceMax = prices.length ? Math.max(...prices) : 0.0; const nScreens = Math.max(1, tree.screens.length); const collapsed = new Set(tree.nodes.filter((n) => n.attrs.collapsed).map((n) => n.node_id)); const repeatCount = new Map(); for (const n of tree.nodes) { const l = this.label(n); if (l && this.isInteractive(n)) repeatCount.set(l, (repeatCount.get(l) || 0) + 1); } const screenDecline = new Map(); for (const [s, nds] of byScreen) { screenDecline.set(s, nds.some( (x) => this.test("DECLINE_LABEL_RE", this.label(x)) && !x.disabled)); } const childrenOf = new Map(); const byId = new Map(tree.nodes.map((n) => [n.node_id, n])); for (const n of tree.nodes) { childrenOf.set(n.node_id, (n.children || []).map((c) => byId.get(c)).filter(Boolean)); } return { stats, peer, groupMembers, repeatCount, screenDecline, signupSteps, cancelSteps, channelChanged, privSpread, adContainers, priceMax, nScreens, collapsed, childrenOf }; } /** Mirrors TreeFeaturizer._node_features. Column order is meta.feature_names. */ nodeFeatures(tree, nd, ctx) { const f = new Float32Array(this.numFeatures); const put = (name, v) => { f[this.fi.get(name)] = v; }; const st = ctx.stats.get(nd.screen) || { area_med: 1.0, font_med: 14.0, contrast_med: 21.0, depth_med: 0.0, n: 1.0, guest: 0.0 }; const label = this.label(nd); const peer = ctx.peer.get(nd.node_id) || null; const members = ctx.groupMembers.get(nd.parent_id || "") || []; const sibAreas = members.filter((m) => m.node_id !== nd.node_id).map((m) => this.area(m)); const sibMaxArea = sibAreas.length ? Math.max(...sibAreas) : 0.0; // geometry / prominence put("log_width", safeLog(nd.bbox[2]) / 7.0); put("log_height", safeLog(nd.bbox[3]) / 7.0); put("log_area", safeLog(this.area(nd)) / 12.0); put("area_ratio_to_sibling_max", ratio(this.area(nd), sibMaxArea)); put("area_ratio_to_screen_median", ratio(this.area(nd), st.area_med)); put("area_ratio_to_choice_peer", peer ? ratio(this.area(nd), this.area(peer)) : 1.0); put("font_ratio_to_choice_peer", peer ? ratio(nd.font_size, peer.font_size) : 1.0); put("font_size_norm", Math.min(3.0, nd.font_size / 16.0)); put("font_ratio_to_screen_median", ratio(nd.font_size, st.font_med)); put("font_weight_norm", nd.font_weight / 700.0); put("rel_y", Math.min(1.0, nd.bbox[1] / 1200.0)); const lumBg = (nd.background[0] + nd.background[1] + nd.background[2]) / 765.0; put("is_primary_styled", nd.role === "button" && lumBg < 0.75 ? 1.0 : 0.0); // contrast const c = this.contrast(nd); put("contrast", Math.min(21.0, c) / 21.0); put("contrast_ratio_to_screen_median", ratio(c, st.contrast_med)); put("below_wcag_aa", c < 4.5 ? 1.0 : 0.0); put("below_wcag_aa_large", c < 3.0 ? 1.0 : 0.0); put("contrast_ratio_to_choice_peer", peer ? ratio(c, this.contrast(peer)) : 1.0); // hierarchy put("depth_norm", Math.min(1.0, nd.depth / 12.0)); put("depth_minus_screen_median", Math.max(-1.0, Math.min(1.0, (nd.depth - st.depth_med) / 6.0))); put("sibling_count_norm", Math.min(1.0, members.length / 10.0)); put("under_collapsed_container", ctx.collapsed.has(nd.parent_id) ? 1.0 : 0.0); put("aria_hidden", nd.aria_hidden ? 1.0 : 0.0); put("hidden", nd.hidden ? 1.0 : 0.0); // state put("default_selected", nd.default_selected ? 1.0 : 0.0); put("is_choice_control", this.isChoice(nd) ? 1.0 : 0.0); put("is_interactive", this.isInteractive(nd) ? 1.0 : 0.0); put("disabled", nd.disabled ? 1.0 : 0.0); put("focusable", nd.focusable ? 1.0 : 0.0); put("live_region", nd.live_region ? 1.0 : 0.0); // path cost put("step_cost_norm", Math.min(1.0, (nd.step_cost || 0) / 6.0)); put("screen_index_norm", Math.min(1.0, nd.screen / 6.0)); const scr = tree.screens.find((s) => s.index === nd.screen); const stepFromEntry = scr ? (scr.step_from_entry ?? nd.screen) : nd.screen; put("screen_step_from_entry_norm", Math.min(1.0, stepFromEntry / 6.0)); put("cancel_vs_signup_step_delta", Math.max(-1.0, Math.min(1.0, (ctx.cancelSteps - ctx.signupSteps) / 4.0))); const isCancelLike = this.isInteractive(nd) && this.test("CANCEL_RE", label); put("is_cancel_entry", isCancelLike && (nd.step_cost || 0) > 0 ? 1.0 : 0.0); put("is_cancel_confirm", isCancelLike && nd.screen > 0 ? 1.0 : 0.0); put("path_channel_changed", ctx.channelChanged); put("privacy_control_step_spread", Math.min(1.0, ctx.privSpread / 4.0)); // wording const toks = tokenize(label, 64); const negs = this.countMatches("NEGATION_RE", label); put("token_count_norm", Math.min(1.0, toks.length / 20.0)); put("negation_count", Math.min(1.0, negs / 3.0)); put("double_negation", negs >= 2 ? 1.0 : 0.0); put("first_person", this.test("FIRST_PERSON_RE", label) ? 1.0 : 0.0); put("self_deprecating", this.test("SELF_DEPRECATING_RE", label) ? 1.0 : 0.0); put("label_len_ratio_to_choice_peer", peer && this.label(peer) ? ratio(label.length, this.label(peer).length) : 1.0); put("urgency_lexicon", this.test("URGENCY_RE", label) ? 1.0 : 0.0); put("scarcity_lexicon", this.test("SCARCITY_RE", label) ? 1.0 : 0.0); put("demand_lexicon", this.test("DEMAND_RE", label) ? 1.0 : 0.0); put("activity_lexicon", this.test("ACTIVITY_RE", label) ? 1.0 : 0.0); put("sponsor_lexicon", this.test("SPONSOR_RE", label) ? 1.0 : 0.0); put("promo_lexicon", this.test("PROMO_RE", label) ? 1.0 : 0.0); put("has_explicit_date", this.test("EXPLICIT_DATE_RE", label) ? 1.0 : 0.0); put("has_source_phrase", this.test("SOURCE_PHRASE_RE", label) ? 1.0 : 0.0); put("protective_direction", this.test("PROTECTIVE_DIRECTION_RE", label) ? 1.0 : 0.0); put("decision_relevant_text", this.test("DECISION_RELEVANT_RE", label) && toks.length >= 3 ? 1.0 : 0.0); put("consent_lexicon", this.test("CONSENT_RE", label) ? 1.0 : 0.0); put("cancel_lexicon", this.test("CANCEL_RE", label) ? 1.0 : 0.0); put("recurrence_lexicon", this.test("RECURRENCE_RE", label) ? 1.0 : 0.0); put("guest_lexicon", this.test("GUEST_RE", label) ? 1.0 : 0.0); put("has_number", this.test("NUMBER_RE", label) ? 1.0 : 0.0); put("has_time_format", this.test("TIMEFMT_RE", label) ? 1.0 : 0.0); const priceValue = this.parsePrice(label); put("has_price", this.test("PRICE_RE", label) ? 1.0 : 0.0); // provenance, derived from what a real tree shows const nearby = members.concat(ctx.childrenOf.get(nd.node_id) || []); put("has_deadline_attr", this.test("EXPLICIT_DATE_RE", label) ? 1.0 : 0.0); put("has_inventory_source", this.test("INVENTORY_SOURCE_RE", label) ? 1.0 : 0.0); put("has_author_attr", this.test("ATTRIBUTION_RE", label) || nearby.some((m) => this.test("ATTRIBUTION_RE", this.label(m))) ? 1.0 : 0.0); put("has_source_attr", this.test("SOURCE_PHRASE_RE", label) || nearby.some((m) => this.test("SOURCE_PHRASE_RE", this.label(m))) ? 1.0 : 0.0); put("has_measurement_window", this.test("MEASUREMENT_WINDOW_RE", label) ? 1.0 : 0.0); put("has_unit_basis", this.test("UNIT_BASIS_RE", label) ? 1.0 : 0.0); const virtual = this.test("VIRTUAL_CURRENCY_RE", label) && priceValue <= 0; const siblingRealPrice = members.some((m) => this.parsePrice(this.label(m)) > 0); put("has_real_currency_equiv", virtual && siblingRealPrice ? 1.0 : 0.0); put("nonstandard_currency", virtual ? 1.0 : 0.0); const isPricey = priceValue > 0 || virtual; put("disclosure_step_norm", isPricey ? Math.min(1.0, nd.screen / 6.0) : 0.0); put("disclosure_after_first_screen", isPricey && nd.screen > 0 ? 1.0 : 0.0); put("label_action_conflict", this.labelActionConflict(label, nd.action) ? 1.0 : 0.0); put("action_absent", (nd.role === "link" || nd.role === "button") && !nd.action ? 1.0 : 0.0); put("user_added", nd.role === "listitem" && !nd.default_selected ? 1.0 : 0.0); put("is_advertising", ctx.adContainers.has(nd.node_id) ? 1.0 : 0.0); const disc = ctx.adContainers.get(nd.node_id); put("ad_disclosure_present_in_container", (disc === undefined ? true : disc) ? 1.0 : 0.0); const rc = ctx.repeatCount.get(label) || 0; put("repeat_of_earlier_prompt", rc > 1 ? 1.0 : 0.0); put("after_decline", rc > 1 && nd.screen > 0 ? 1.0 : 0.0); const decl = ctx.screenDecline.has(nd.screen) ? ctx.screenDecline.get(nd.screen) : true; put("dismissible_absent", nd.role === "button" && !decl ? 1.0 : 0.0); // group relations const interMembers = members.filter((m) => this.isInteractive(m)); const hasDecline = members.some( (m) => this.test("DECLINE_LABEL_RE", this.label(m)) && !m.disabled); put("choice_group_has_decline", hasDecline ? 1.0 : 0.0); put("choice_group_size_norm", Math.min(1.0, interMembers.length / 6.0)); const choiceMembers = members.filter((m) => this.isChoice(m)); put("group_all_preselected", choiceMembers.length && choiceMembers.every((m) => m.default_selected) ? 1.0 : 0.0); const groupPrices = members.map((m) => this.parsePrice(this.label(m))); const gmax = groupPrices.length ? Math.max(...groupPrices) : 0.0; put("is_max_priced_in_group", priceValue > 0 && gmax > 0 && priceValue >= gmax ? 1.0 : 0.0); put("price_vs_group_max", ratio(priceValue, ctx.priceMax, 2.0)); put("guest_path_present_on_screen", st.guest); put("screen_count_norm", Math.min(1.0, ctx.nScreens / 6.0)); put("screens_after_this_norm", Math.min(1.0, Math.max(0.0, ctx.nScreens - 1 - nd.screen) / 6.0)); return f; } /** Featurise a whole tree. Returns arrays aligned with `nodeIds`. */ featurize(rawTree) { const tree = this.normalise(rawTree); const ctx = this.context(tree); const n = tree.nodes.length; const T = this.maxTokens; const tokens = new BigInt64Array(n * T); const tokenMask = new Float32Array(n * T); const roles = new BigInt64Array(n); const screen = new BigInt64Array(n); const numeric = new Float32Array(n * this.numFeatures); tree.nodes.forEach((nd, i) => { roles[i] = BigInt(this.roleToId.get(nd.role) ?? this.roleToId.get("generic")); screen[i] = BigInt(nd.screen); const ids = this.vocab.encode(tokenize(this.label(nd), T)); for (let j = 0; j < Math.min(ids.length, T); j++) { tokens[i * T + j] = BigInt(ids[j]); tokenMask[i * T + j] = 1.0; } numeric.set(this.nodeFeatures(tree, nd, ctx), i * this.numFeatures); }); return { nodeIds: tree.nodes.map((x) => x.node_id), nodes: tree.nodes, screens: tree.screens, n, T, F: this.numFeatures, tokens, tokenMask, roles, screen, numeric, }; } }