Download featurizer.js from NagaYu/sludge: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/spaces/NagaYu/sludge/resolve/main/featurizer.js
- Command line
-
hf download hf://spaces/NagaYu/sludge/featurizer.js
-
curl -L -o featurizer.js https://huggingface.co/spaces/NagaYu/sludge/resolve/main/featurizer.js
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, | |
| }; | |
| } | |
| } | |