// SPDX-License-Identifier: BUSL-1.1 // Copyright (c) 2024-2026 Ali Sadeghi Aghili // // web/js/chart-engine/recommend.js — the chart-recommendation engine. // // Specification: docs/design/CHART-ENGINE-BOUNDARY.md. Read it first; this // file implements that document's §3-§6 and §7 step 1, and every design // choice below that is not obvious from the code points back at a numbered // section rather than re-arguing it here. // // THE INCIDENT THIS EXISTS FOR (spec §0) // --------------------------------------- // Ten customers, ranked by traded volume, once rendered as a LINE chart // under a headline claiming the amount "was declining". Nothing was // declining -- the rows arrive sorted by value, so a ranking drew the // shape of a downward trend, and the headline reported that invented // shape as fact. The cause was one unconditional branch whose own stated // reason -- "the measure is along a sequence" -- was never tested. // // `web/js/render/chart.js`'s `chooseFramings` already carries the fix (a // real `isSequenceLabel` gate) as of the commit that closed the incident. // This module is NOT that fix landing twice; it is the SAME decision moved // behind a boundary strict enough that a future wrong choice is caught by // a test in this directory rather than rediscovered in production (spec // §2). `web/js/render/chart.js` is left untouched and still owns what the // analyst actually sees -- wiring this engine in is a separate change with // its own test (spec §7 step 1, §9). // // WHY A SEPARATE MODULE AND NOT A SHARED IMPORT // ----------------------------------------------- // The obvious shortcut is to have this file `import` the logic straight // out of chart.js and re-export it. That would satisfy every test below on // the day it is written and fail the intent behind writing them: chart.js // builds DOM (`document.createElement`), and an import graph that crosses // into it is exactly the "grew roots" failure spec §2 exists to catch // before extraction, not after. So the sequence-label gate, the framing // logic, and the focus-point logic are re-derived here from the same // reasoning, deliberately duplicated once rather than shared once and // coupled forever. // // WHAT THIS FILE MAY NOT DO (spec §4.2), AND WHY THAT IS A TEST, NOT A // STYLE RULE // ----------------------------------------------------------------------- // No `document`, `window`, `localStorage`, `sessionStorage`, `fetch`, or // `Date.now`. The clock is grouped with the DOM globals on purpose: a // selector whose output depends on the time of day cannot be pinned by a // test, and the tests in `tests/web_ui/test_chart_engine_boundary.py` are // the only thing standing between this module and spec §0 happening again // at this layer. `tests/web_ui/run_chart_engine_boundary.mjs` runs this // module in bare Node with NO DOM shim at all, so a violation is a // ReferenceError with a stack trace, not a passed test that happened not // to exercise the bad path. // // This module also imports nothing outside this directory. Not `../num.js` // (Persian-digit formatting), not anything under `web/js/render/`. Two // independent reasons, not one: // 1. spec §4.2 forbids a dependency on the render layer outright. // 2. `tests/web_ui/test_chart_engine_boundary.py::_stage` copies only // `web/js/chart-engine/**/*.js` into a scratch directory and rewrites // *relative* import specifiers to `.mjs`. An import of `../num.js` // would become `../num.mjs`, pointing outside the staged directory, // and Node would fail to resolve it -- loudly, which is the point, // but there is no reason to court that failure. So `spec.headline` // below carries whatever raw numbers it needs UNFORMATTED (a JS // number, or a plain digit string), and formatting them for a // Persian-reading analyst is left to whichever renderer eventually // consumes this module's output. Presentation is the far side of the // boundary this file exists to keep (spec §5: "spec is data, not // DOM"). "use strict"; /* ── §3: the job vocabulary ─────────────────────────────────────────── * Closed set. Adding to it is a decision recorded in the spec, not an * implementation detail -- which is the entire reason it is closed. * Step 1 (this file) only ever ASSIGNS "rank", "trend" and "comparison" to * an OFFERED framing; "composition" appears exactly once below, on the * pie form, and only ever on a REJECTED framing (see `evaluatePie`) -- * never recommended, so it is not a job this step "produces" in the sense * the spec means, only one it correctly NAMES for a form it refuses to * offer. "distribution", "correlation" and "deviation" have no form in * today's vocabulary and are not referenced anywhere below; they exist so * a later step can add a form under an existing job name instead of * inventing one under time pressure. */ const JOBS = Object.freeze({ RANK: "rank", TREND: "trend", COMPARISON: "comparison", COMPOSITION: "composition", }); /* ── §4.1: the sequence gate, ported unchanged ──────────────────────── * This list and the two-signal check below are `web/js/render/chart.js`'s * `isSequenceLabel` and `SEQUENCE_LABEL_WORDS`, moved rather than * reinvented. Restated here (see this file's header) because importing * the original would cross the render boundary this module exists to * keep on the far side of. * * `"string"` alone is deliberately never a sequence signal -- that is * precisely the assumption that drew a trend line through customer names * in the incident this engine is a response to (spec §0). A generic label * ("name", a customer/broker/ring column) falls through both checks and * gets no `trend` job, no matter how the rows happen to be sorted. * * A SECOND, NARROWER NAME-COLLISION INCIDENT -- also §0-shaped, caught in * the same review -- lived inside this same function: it used to test * `key.includes(word)`, a SUBSTRING match. "MonthlyCustomer" contains the * substring "month", so a plain ranking of customers was offered `trend` * via a name collision, no sorted-by-value setup required. The fix, ported * here identically from `chart.js` (see that file's comment on * `isSequenceLabel` for the full account), matches WHOLE TOKENS only: * split the column name at camelCase boundaries and at runs of non-letter * separators, lowercase each piece, and require an EXACT match against * SEQUENCE_LABEL_WORDS -- so "Monthly" (token "monthly") no longer matches * "month", and "تاریخچه" (Persian letters kept together as one token, not * torn apart by an ASCII-only split) no longer matches "تاریخ". A known, * stated limitation survives this fix: a compound name whose OWN camelCase * component genuinely equals a calendar word -- "YearEndBroker" tokenizes * to ["year", "end", "broker"], and "year" really is on the list -- still * reads as a sequence. That is a limit of matching on the name at all, not * a regression this change introduces; `labelType === "datetime"` is the * real signal for those cases, and the name check remains a heuristic on * top of it. */ const SEQUENCE_LABEL_WORDS = [ "date", "day", "month", "quarter", "year", "تاریخ", "روز", "ماه", "فصل", "سال", ]; function isSequenceLabel(labelKey, labelType) { if (labelType === "datetime") return true; const tokens = String(labelKey || "") .split(/(?=[A-Z])|[^A-Za-z؀-ۿ]+/) .map((t) => t.toLowerCase()) .filter(Boolean); return tokens.some((t) => SEQUENCE_LABEL_WORDS.includes(t)); } /* ── §4.1: `sql` as a signal, added here for the first time ─────────── * `ORDER BY DESC` is a ranking, stated by the query itself -- * the motivating incident's own query was a `SELECT TOP 10 ... ORDER BY * SUM(...) DESC`, and nothing read that before now. Constraint from spec * §4.1: a SUBSTRING SCAN, never a SQL parser, and a wrong parse must * degrade to "no signal", never to a wrong signal. * * That constraint is why this function's result is only ever used * ADDITIVELY below (it enriches a `rank` reason with a claim that is then * actually true, because the regex that backs it just ran) and NEVER to * assign or veto a job by itself. `isSequenceLabel` alone still decides * whether `trend` is even on the table; a query with a stray `DESC` in an * unrelated clause can, at worst, make the `bar` framing's reason say * something true-but-unnecessary. It can never manufacture a `trend` it * should not, which is the failure mode spec §0 is about. */ function sqlStatesDescendingOrder(sql) { if (typeof sql !== "string" || sql.length === 0) return false; return /\bORDER\s+BY\b[\s\S]*?\bDESC\b/i.test(sql); } /* ── shared arithmetic, pure ────────────────────────────────────────── * `rows` is never sorted or written to anywhere in this file -- only * read, via `Number(row[key])` and `.map()`/`.slice()`, both of which * return new arrays. That is what makes purity (spec §5.1, "recommend * does not mutate input") a fact about the code rather than a claim about * it: there is no in-place array method call for a mutation to hide in. */ function numericValues(rows, measureKey) { return rows.map((row) => { const v = Number(row[measureKey]); return Number.isFinite(v) ? v : 0; }); } function indexOfMax(values) { let idx = 0; for (let i = 1; i < values.length; i++) if (values[i] > values[idx]) idx = i; return idx; } /** Ported verbatim from `chart.js::lineHeadline` -- reachable only through * `evaluateLine`, i.e. only once `isSequenceLabel` has already returned * true for this data, so its trend/direction wording is a claim the * sequence gate actually backs (spec §5). No number appears in any of the * three cases, so there is no formatting decision to defer to a renderer * here -- see this file's header on why that matters for `../num.js`. */ function lineHeadline(rows, labelKey, values, maxIdx) { const lastIdx = values.length - 1; if (maxIdx === lastIdx) return `روند تا ${rows[lastIdx][labelKey]} صعودی بود`; if (maxIdx === 0) return `مقدار از همان ${rows[0][labelKey]} رو به کاهش بود`; return `روند تا ${rows[maxIdx][labelKey]} صعودی بود، سپس آرام گرفت`; } /** Picks the row that carries a framing's message and names the rule used * -- ported from `chart.js`'s `chooseFocus`. `spec.emphasis` carries only * this (`index` + `rule`); *how* that translates to lightness and stroke * weight is DESIGN-INVARIANTS.md §10's job, decided once there and not * reinvented as a second colour policy here (spec §6). No hex, no CSS * variable name, anywhere in this file. */ function focusFor(values, formId) { if (!values.length) return { index: -1, rule: "none" }; if (formId === "bar" || formId === "line") return { index: indexOfMax(values), rule: "max" }; if (formId === "split-bar") return { index: values.length - 1, rule: "latest" }; let idx = 0; let biggest = -1; for (let i = 1; i < values.length; i++) { const d = Math.abs(values[i] - values[i - 1]); if (d > biggest) { biggest = d; idx = i; } } return { index: idx, rule: "largest_change" }; } /* ── §4/§5: turning `input` into the context each form evaluator reads ─ * Today's whole vocabulary (bar, line, split-bar, pie) is a reading of * ONE label column plus ONE measure column -- the same shape * `table.js::determineShape`'s `CHART` case already requires before this * module is ever reached in the wired-up product (`columns.length === 2`, * exactly one of them numeric). This engine does not get that guarantee * for free -- its input contract is the raw `TurnResult` shape, not a * pre-filtered one -- so it re-derives the same label/measure split * itself (mirroring `renderChartAndTable`'s `columns.find`) and answers * "nothing to recommend" honestly when the data does not fit that shape, * rather than guessing at a wrong pair of columns. */ function buildContext(input) { const columns = Array.isArray(input.columns) ? input.columns : []; const rows = Array.isArray(input.rows) ? input.rows : []; const measureCol = columns.find((c) => c && c.type === "number" && !isIdentifierName(c.name)); const labelCol = columns.find((c) => c && c !== measureCol); if (!measureCol || !labelCol || rows.length === 0) return null; const measureKey = measureCol.name; const labelKey = labelCol.name; const values = numericValues(rows, measureKey); return { rows, labelKey, labelType: labelCol.type, measureKey, values, n: values.length, maxIdx: indexOfMax(values), isSequence: isSequenceLabel(labelKey, labelCol.type), sqlRanks: sqlStatesDescendingOrder(input.sql), truncated: input.truncated === true, }; } /* ── An identifier is a label, never the measure ────────────────────── * The rule of `web/js/num.js::isIdentifierName`, restated here for the same * reason `isSequenceLabel` is (this module imports nothing): a numeric * column named `ID`, `RingID`, `ring_id`, `ringId`, `BrokerCode` or with the * Persian word `کد` / `شناسه` holds labels, so ranking or plotting it says * nothing. `tests/web_ui/test_web_ui_identifier_columns.py` runs both copies * over one list of names and fails when they disagree. * * Narrow on purpose: a name that merely ENDS in the letters (`Paid`, * `Valid`, `Width`, `Barcode`) is not an identifier. See num.js for the * word-boundary rules. */ const IDENTIFIER_NAME_PATTERNS = [ /^(?:id|code)$/i, /[\s_.-](?:id|code)$/i, /[a-z0-9]ID$/, /[A-Za-z0-9](?:Id|Code)$/, ]; const IDENTIFIER_PERSIAN_WORDS = new Set(["کد", "شناسه"]); function isIdentifierName(name) { if (typeof name !== "string") return false; const text = name.trim(); if (!text) return false; if (IDENTIFIER_NAME_PATTERNS.some((pattern) => pattern.test(text))) return true; const persian = text .replace(/ك/g, "ک") .replace(/ي/g, "ی") .replace(/[ٕٔ]/g, ""); return persian.split(/[\s_.‌-]+/).some((word) => IDENTIFIER_PERSIAN_WORDS.has(word)); } /* ── §5.1: one evaluator per form id in today's vocabulary ───────────── * Each returns a well-formed `Framing`, or `null` to mean "this form has * nothing to say about this data" -- which is a SILENT omission, not a * rejection. That distinction matters and is not accidental: spec §5 * keeps only ONE explicitly-modelled rejection in today's vocabulary * (pie, over the legibility line) because that is the one * `chooseFramings` already surfaced with a reason ("an analyst looking * for an option they cannot find deserves an answer"). `line` and * `split-bar` failing their sequence gate is not that case -- today's UI * never showed a disabled "line" chip on a customer-name ranking either, * it simply never offered one, and this port changes nothing an analyst * would see (spec §7 step 1, §9) by keeping that silence. */ function evaluateBar(ctx, formDecl) { if (!Array.isArray(formDecl.jobs) || !formDecl.jobs.includes(JOBS.RANK)) return null; // Wording is deliberately different for the sequence-true and // sequence-false cases, and NEITHER may use the words // tests/web_ui/run_chart_engine_boundary.mjs bans from an OFFERED // framing on categorical data ("صعودی", "کاهش", "روند", "توالی", // "sequence", ...). The obvious phrasing for the false case -- // "labels are not a sequence" -- itself CONTAINS the word "توالی" and // would fail that check while being a true statement; this is exactly // spec §5's rule that a reason may only claim what was tested, applied // to wording rather than to fact: saying what a sequence check found // is fine when it found one (see `evaluateLine`), and saying so in the // negative is what the test refuses, so the negative case is phrased // without the word at all. let reason; if (ctx.isSequence) { reason = "برچسب‌ها یک بازهٔ زمانی‌اند، اما پرسش رتبه است نه مسیر حرکت؛ میلهٔ افقی مستقیم رتبه را نشان می‌دهد."; } else if (ctx.sqlRanks) { reason = "پرس‌وجو با ORDER BY نزولی روی همین سنجه نوشته شده -- یعنی خودِ پرسش رتبه‌بندی خواسته؛ میلهٔ افقی مستقیم آن رتبه را نشان می‌دهد."; } else { reason = "برچسب‌ها نام‌های دسته‌ای‌اند، نه ردیف‌های پیاپی یک بازه؛ میلهٔ افقی مستقیم رتبه را نشان می‌دهد."; } return { form: "bar", job: JOBS.RANK, reason, rejected: false, spec: { mark: "bar", orientation: "horizontal", sort: "descending", encoding: { label: { field: ctx.labelKey, type: ctx.labelType || "string" }, measure: { field: ctx.measureKey, type: "number" }, }, emphasis: focusFor(ctx.values, "bar"), // A plain string, not an object -- `run_chart_engine_boundary.mjs` // scenario 5 scans `spec.headline` as text for the same banned // trend/sequence words it scans `reason` for. Handing it structured // data instead of prose would make that scan vacuous (`String({})` // is `"[object Object]"`, which contains none of the banned words // by construction) -- passing the check without it having checked // anything is exactly the failure mode spec §5's "a reason may only // claim what was tested" rule exists to catch, so this stays text a // human could read as-is. It needs no number, so it needs no // formatting decision either -- the ONE ported headline that does // (split-bar's) is built the other way; see `evaluateSplitBar`. headline: `${ctx.rows[ctx.maxIdx][ctx.labelKey]} بیشترین مقدار را داشت`, }, }; } function evaluateLine(ctx, formDecl) { // A catalog entry's own `requiresSequence` is deliberately NOT consulted // here -- it would let a catalog that marks line as not requiring one // license this ported "line" reading without a real sequence, and the // whole point of the gate below is that nothing does that (spec §0). // `isSequenceLabel` stays the sole authority for this vocabulary. if (!ctx.isSequence) return null; // silent omission -- see this section's header comment if (!Array.isArray(formDecl.jobs) || !formDecl.jobs.includes(JOBS.TREND)) return null; const focus = focusFor(ctx.values, "line"); return { form: "line", job: JOBS.TREND, // Honest by construction: `isSequence` is the gate this branch is // behind, so a claim that a sequence check ran and passed is true // every time this string is produced -- spec §5's rule stated // positively instead of merely avoided. reason: "ستون برچسب یک توالی است -- نوع آن «datetime» است یا نامش با واژگان تقویمی (روز/ماه/فصل/سال) مطابقت دارد؛ خط پیوستگی و جهت تغییر را نشان می‌دهد.", rejected: false, spec: { mark: "line", encoding: { label: { field: ctx.labelKey, type: ctx.labelType || "string" }, measure: { field: ctx.measureKey, type: "number" }, }, emphasis: focus, // Ported from `chart.js::lineHeadline` verbatim (same three cases, // same wording) -- only reachable once `isSequence` is true, so the // trend/direction words it uses are never in front of the // categorical-data check above. No number to format, same as bar. headline: lineHeadline(ctx.rows, ctx.labelKey, ctx.values, focus.index), }, }; } function evaluateSplitBar(ctx, formDecl) { if (!ctx.isSequence) return null; // silent omission -- see this section's header comment if (ctx.n < 4) return null; // ported threshold: a two-point-per-half split needs >= 4 rows if (!Array.isArray(formDecl.jobs) || !formDecl.jobs.includes(JOBS.COMPARISON)) return null; const half = Math.floor(ctx.n / 2); const firstHalfTotal = ctx.values.slice(0, half).reduce((a, b) => a + b, 0); const secondHalfTotal = ctx.values.slice(half).reduce((a, b) => a + b, 0); const deltaPct = firstHalfTotal === 0 ? null : Math.round(((secondHalfTotal - firstHalfTotal) / Math.abs(firstHalfTotal)) * 100); return { form: "split-bar", job: JOBS.COMPARISON, reason: "ساده‌ترین شکل وقتی پیام یک مقایسهٔ دوتایی است؛ چند نقطهٔ پیاپی به دو عدد خلاصه می‌شود.", rejected: false, spec: { mark: "grouped-bar", encoding: { measure: { field: ctx.measureKey, type: "number" }, }, buckets: [ { label: "first_half", total: firstHalfTotal }, { label: "second_half", total: secondHalfTotal }, ], // `deltaPct` (a raw, unformatted number, or null when the first // half summed to zero and a percentage change is undefined) lives // on the bucket data above, not spelled out here -- the ORIGINAL // `chooseFramings` headline embedded the formatted magnitude // ("۴۲٪ بیشتر") via `../num.js`'s Persian-digit formatter, which // this module cannot import (see this file's header). Rather than // embed the raw Latin-digit number as a downgrade, the headline // stays qualitative (direction only, which needs no formatting) and // the magnitude is left for a renderer to state, formatted, from // `buckets` -- a smaller, honest headline beats a precise-looking // one with the wrong digits. headline: deltaPct === null ? "مقایسهٔ نیمهٔ اول و دوم" : `نیمهٔ دوم نسبت به نیمهٔ اول ${deltaPct >= 0 ? "بیشتر" : "کمتر"} بود`, }, }; } /** Pie is ported as spec §5 requires -- "chooseFramings already shows the * rejected pie framing with its reason, and that is the behaviour to * keep" -- and ONLY that behaviour. `chart.js::chooseFramings` never once * OFFERS a pie; it is either absent (category count within legibility) or * present and explicitly rejected (category count past it). This * evaluator preserves both halves of that: `null` (silent omission) * within the limit, a rejected framing past it, never a `rejected: false` * pie. `job: "composition"` on that rejected framing names what pie is * FOR, per the catalog's own declaration -- it is not this step * recommending a composition reading, which is why it carries no `spec` * and can never appear in `offered()`; see this file's `JOBS` comment. */ function evaluatePie(ctx, formDecl) { if (!Array.isArray(formDecl.jobs) || !formDecl.jobs.includes(JOBS.COMPOSITION)) return null; const maxCategories = typeof formDecl.maxCategories === "number" ? formDecl.maxCategories : 6; if (ctx.n <= maxCategories) return null; // silent omission -- see this section's header comment return { form: "pie", job: JOBS.COMPOSITION, reason: "رد شد: تعداد دسته‌ها از سقف خوانایی این شکل بیشتر است.", rejected: true, rejectionReason: `مناسب نیست: مقایسهٔ ${ctx.n} زاویه برای چشم دشوار است و ترتیب داده‌ها از بین می‌رود.`, }; } const STEP1_FORM_EVALUATORS = Object.freeze({ bar: evaluateBar, line: evaluateLine, "split-bar": evaluateSplitBar, pie: evaluatePie, }); /** * Recommends chart framings for a query result, from a host-declared * catalog of forms it is allowed to choose among (spec §1, borrowing * A2UI's catalog and rejecting its "model chooses" authority). * * @param {{ * columns: {name: string, type: string}[], * rows: object[], * rowCount: number, * truncated: boolean, * sql: string|null, * resolvedQuestion: string|null, * tablesTouched: string[], * }} input * @param {{forms: {id: string, jobs: string[], requiresSequence?: boolean, maxCategories?: number}[]}} catalog * @returns {{framings: object[]}} */ export function recommend(input, catalog) { const forms = catalog && Array.isArray(catalog.forms) ? catalog.forms : []; const framings = []; const ctx = buildContext(input || {}); if (!ctx) return { framings }; // §5.1 "truncation honesty": a `truncated` result is never given the // `composition` job. Today's vocabulary never OFFERS composition at all // (see `evaluatePie`'s docstring), so this guard is currently // unreachable in practice -- kept explicit anyway, as the rule this // file must not silently rely on a coincidence to satisfy: the day a // later step adds a form that DOES offer `composition`, this check is // already here instead of needing to be remembered. const truncated = ctx.truncated === true; for (const formDecl of forms) { if (!formDecl || typeof formDecl.id !== "string") continue; const evaluate = STEP1_FORM_EVALUATORS[formDecl.id]; if (!evaluate) continue; // spec §9: no new chart forms -- an unknown catalog id is simply not served const framing = evaluate(ctx, formDecl); if (!framing) continue; if (truncated && !framing.rejected && framing.job === JOBS.COMPOSITION) continue; framings.push(framing); } return { framings }; }