sludge / featurizer.js
NagaYu's picture
Add featurizer.js
ab36084 verified
Raw History Blame Contribute Delete
21.7 kB
/**
* 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,
};
}
}