{"version":3,"file":"compact.cjs","names":[],"sources":["../../src/tabular/compact.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the compact model format: the header, the column\n * dictionary, the quantised weights and the decoder that reads them back. Writer and\n * reader in one file because a format whose two halves live apart drifts.\n */\n/**\n * Running a model with no inference runtime at all.\n *\n * ONNX in the browser costs a **25.6 MB WebAssembly runtime** (6.0 MB\n * gzipped) before the first prediction, while the model itself is a few\n * hundred kilobytes. For an app whose only model is tabular, that runtime\n * *is* the download — so this reader replaces it with 1.49 KB of\n * arithmetic (measured, brotli).\n *\n * A linear model is a dot product. A tree is a chain of comparisons. That\n * is the whole implementation; there is nothing here that a WebAssembly\n * kernel would do better at this size.\n *\n * The file it reads (`.tmc`, magic `TMC1`) is written by\n * `tempest-fastapi-sdk`'s `export_sklearn_to_compact`, which verifies the\n * bytes against scikit-learn's own predictions and refuses to write a file\n * that disagrees. **It is data, not code**: no `eval`, no generated\n * JavaScript, nothing a strict CSP forbids.\n *\n * The trade against {@link TabularPredictor}: ONNX covers every estimator,\n * this covers linear models and tree ensembles. Pick by what your app\n * already ships — a page that loads ONNX for vision pays nothing extra for\n * tabular ONNX.\n */\n\nimport { CompactFormatError, FeatureShapeError, ModelFetchError } from \"./exceptions\";\nimport type { FeatureRow, PredictedLabel, TabularPrediction } from \"./types\";\n\n/** Magic bytes opening every compact file. */\nconst MAGIC = \"TMC1\";\n\n/** Layout version this reader understands. */\nexport const SUPPORTED_COMPACT_SCHEMA = 1;\n\n/** What the file holds. */\nexport type CompactKind = \"linear\" | \"tree_ensemble\";\n\n/** How raw scores become probabilities. */\ntype CompactLink = \"softmax\" | \"sigmoid\" | \"normalize\" | \"identity\";\n\n/** One array stored after the header. */\ninterface CompactSection {\n    readonly name: string;\n    readonly dtype: \"float32\" | \"int32\";\n    readonly length: number;\n}\n\n/** The parsed header. */\ninterface CompactHeader {\n    readonly schema_version: number;\n    readonly kind: CompactKind;\n    readonly task: \"classification\" | \"regression\";\n    readonly link: CompactLink;\n    readonly classes: readonly string[];\n    /** How scikit-learn typed those labels: ``int``, ``float`` or ``str``. */\n    readonly class_type?: \"int\" | \"float\" | \"str\";\n    readonly n_features: number;\n    readonly n_outputs: number;\n    readonly n_trees?: number;\n    readonly estimator: string;\n    readonly feature_names: readonly string[];\n    readonly preprocess: { readonly offset: number[]; readonly scale: number[] } | null;\n    readonly sections: readonly CompactSection[];\n}\n\n/** What a loaded compact model is. */\nexport interface CompactPredictorInfo {\n    /** Which reader path the file uses. */\n    readonly kind: CompactKind;\n    /** Class labels in score-column order; empty for a regressor. */\n    readonly classes: readonly string[];\n    /** Values expected per row. */\n    readonly numFeatures: number;\n    /** Column order recorded at training time, when the export had one. */\n    readonly featureNames: readonly string[];\n    /** Trees in the ensemble; `0` for a linear model. */\n    readonly numTrees: number;\n    /** Whether the model produces class scores. */\n    readonly isClassifier: boolean;\n    /** Class name of the exported estimator. */\n    readonly estimator: string;\n}\n\n/**\n * A compact model, loaded and ready to answer.\n *\n * @example\n * ```ts\n * const predictor = await CompactPredictor.create(\"/models/risk.tmc\");\n * const { labels, probabilities } = await predictor.predict([[5.1, 3.5, 1.4, 0.2]]);\n * ```\n */\nexport class CompactPredictor {\n    private constructor(\n        private readonly header: CompactHeader,\n        private readonly arrays: Record<string, Float32Array | Int32Array>,\n        /** What is loaded. */\n        public readonly info: CompactPredictorInfo,\n    ) {}\n\n    /**\n     * Load a `.tmc` file.\n     *\n     * @param source A URL, or the bytes when the app already has them.\n     * @param requestInit `fetch` options, when `source` is a URL.\n     * @returns The loaded predictor.\n     * @throws {@link ModelFetchError} when a URL cannot be read.\n     * @throws {@link CompactFormatError} when the bytes are not a compact\n     *   model, or use a layout newer than this reader.\n     */\n    static async create(\n        source: string | ArrayBuffer | Uint8Array,\n        requestInit?: RequestInit,\n    ): Promise<CompactPredictor> {\n        const buffer = await toBuffer(source, requestInit);\n        const bytes = new Uint8Array(buffer);\n\n        if (String.fromCharCode(...bytes.subarray(0, 4)) !== MAGIC) {\n            throw new CompactFormatError(\n                \"This is not a compact model file: it does not start with \" +\n                    `\"${MAGIC}\". A .onnx file goes through TabularPredictor instead.`,\n            );\n        }\n\n        const view = new DataView(buffer);\n        const headerLength = view.getUint32(4, true);\n        const header = JSON.parse(\n            new TextDecoder().decode(bytes.subarray(8, 8 + headerLength)),\n        ) as CompactHeader;\n\n        if (header.schema_version > SUPPORTED_COMPACT_SCHEMA) {\n            throw new CompactFormatError(\n                `This file uses compact layout ${header.schema_version}, and this ` +\n                    `SDK understands ${SUPPORTED_COMPACT_SCHEMA}. Upgrade ` +\n                    \"tempest-react-sdk before serving it.\",\n            );\n        }\n\n        const arrays: Record<string, Float32Array | Int32Array> = {};\n        let cursor = 8 + headerLength;\n        for (const section of header.sections) {\n            arrays[section.name] =\n                section.dtype === \"float32\"\n                    ? new Float32Array(buffer, cursor, section.length)\n                    : new Int32Array(buffer, cursor, section.length);\n            cursor += section.length * 4;\n        }\n\n        return new CompactPredictor(header, arrays, {\n            kind: header.kind,\n            classes: header.classes,\n            numFeatures: header.n_features,\n            featureNames: header.feature_names,\n            numTrees: header.n_trees ?? 0,\n            isClassifier: header.task === \"classification\",\n            estimator: header.estimator,\n        });\n    }\n\n    /**\n     * Predict for a batch of rows.\n     *\n     * @param rows One array of feature values per row, in training column\n     *   order. A single row is still wrapped: `[[...]]`.\n     * @returns Labels, class scores when the model is a classifier, and the\n     *   call's duration.\n     * @throws {@link FeatureShapeError} when the batch is empty, ragged, or\n     *   the wrong width.\n     */\n    async predict(rows: readonly FeatureRow[]): Promise<TabularPrediction> {\n        const width = validateRows(rows, this.header.n_features);\n        const started = performance.now();\n\n        const scores: number[][] = [];\n        for (const row of rows) {\n            const prepared = this.preprocess(row, width);\n            scores.push(\n                this.header.kind === \"linear\"\n                    ? this.linearScores(prepared)\n                    : this.treeScores(prepared),\n            );\n        }\n\n        const { labels, probabilities } = this.finish(scores);\n        return {\n            labels,\n            probabilities,\n            numRows: rows.length,\n            ms: performance.now() - started,\n        };\n    }\n\n    /** Releasing nothing, so callers can swap predictors without branching. */\n    async dispose(): Promise<void> {\n        /* no runtime, nothing to release */\n    }\n\n    /**\n     * Apply the folded scaler, when the export had one.\n     *\n     * @param row The raw feature values.\n     * @param width How many there are.\n     * @returns The values the model was trained on.\n     */\n    private preprocess(row: FeatureRow, width: number): number[] {\n        const preprocess = this.header.preprocess;\n        if (preprocess === null) return row as number[];\n        const scaled = new Array<number>(width);\n        for (let index = 0; index < width; index += 1) {\n            scaled[index] =\n                ((row[index] as number) - (preprocess.offset[index] as number)) /\n                (preprocess.scale[index] as number);\n        }\n        return scaled;\n    }\n\n    /**\n     * Score one row against the coefficient matrix.\n     *\n     * @param row The prepared feature values.\n     * @returns One raw score per output.\n     */\n    private linearScores(row: readonly number[]): number[] {\n        const coef = this.arrays.coef as Float32Array;\n        const intercept = this.arrays.intercept as Float32Array;\n        const features = this.header.n_features;\n        const outputs = this.header.n_outputs;\n\n        const scores = new Array<number>(outputs);\n        for (let output = 0; output < outputs; output += 1) {\n            let total = intercept[output] as number;\n            const base = output * features;\n            for (let index = 0; index < features; index += 1) {\n                total += (coef[base + index] as number) * (row[index] as number);\n            }\n            scores[output] = total;\n        }\n        return scores;\n    }\n\n    /**\n     * Walk every tree and average what the leaves hold.\n     *\n     * A leaf is marked by a negative `feature` entry, which also carries\n     * its slot in the value array — so the walk needs no second lookup and\n     * the file stores values only for leaves.\n     *\n     * **The comparison runs in float32** (`Math.fround`), because that is\n     * what scikit-learn does: it casts its input to float32 before\n     * traversing, so a threshold like 5.099999904632568 — a float32 value\n     * widened for storage — and an input of 5.1 compare *equal* and go\n     * left. Comparing in float64 sends that row right instead, which on an\n     * iris forest changed one tree's vote and moved a probability by 0.05.\n     *\n     * @param row The prepared feature values.\n     * @returns One averaged score per output.\n     */\n    private treeScores(row: readonly number[]): number[] {\n        const feature = this.arrays.node_feature as Int32Array;\n        const threshold = this.arrays.node_threshold as Float32Array;\n        const left = this.arrays.node_left as Int32Array;\n        const right = this.arrays.node_right as Int32Array;\n        const leaf = this.arrays.leaf_value as Float32Array;\n        const offsets = this.arrays.tree_offset as Int32Array;\n        const outputs = this.header.n_outputs;\n        const trees = offsets.length - 1;\n\n        const totals = new Array<number>(outputs).fill(0);\n        for (let tree = 0; tree < trees; tree += 1) {\n            let node = offsets[tree] as number;\n            let column = feature[node] as number;\n            while (column >= 0) {\n                node =\n                    Math.fround(row[column] as number) <= (threshold[node] as number)\n                        ? (left[node] as number)\n                        : (right[node] as number);\n                column = feature[node] as number;\n            }\n            const slot = (-1 - column) * outputs;\n            for (let output = 0; output < outputs; output += 1) {\n                totals[output] += leaf[slot + output] as number;\n            }\n        }\n        for (let output = 0; output < outputs; output += 1) {\n            totals[output] = (totals[output] as number) / trees;\n        }\n        return totals;\n    }\n\n    /**\n     * Turn raw scores into labels and probabilities.\n     *\n     * @param scores One score array per row.\n     * @returns Labels and probabilities in the shape the ONNX route uses,\n     *   so an app can swap runtimes without touching its own code. That\n     *   includes the label's **type**: an integer class comes back as a\n     *   number here exactly as ONNX returns it, because two routes over one\n     *   model that disagree on `0` versus `\"0\"` break the day someone\n     *   switches.\n     */\n    private finish(scores: readonly number[][]): {\n        labels: PredictedLabel[];\n        probabilities: number[][];\n    } {\n        const link = this.header.link;\n        const classes = this.header.classes;\n\n        if (link === \"identity\") {\n            return { labels: scores.map((row) => row[0] as number), probabilities: [] };\n        }\n\n        const probabilities = scores.map((row) => {\n            if (link === \"sigmoid\") {\n                const positive = 1 / (1 + Math.exp(-(row[0] as number)));\n                return [1 - positive, positive];\n            }\n            if (link === \"softmax\") {\n                const highest = Math.max(...row);\n                const exponentiated = row.map((value) => Math.exp(value - highest));\n                const total = exponentiated.reduce((sum, value) => sum + value, 0);\n                return exponentiated.map((value) => value / total);\n            }\n            const total = row.reduce((sum, value) => sum + value, 0);\n            return total === 0 ? [...row] : row.map((value) => value / total);\n        });\n\n        const numeric = this.header.class_type !== \"str\";\n        const labels = probabilities.map((row) => {\n            let best = 0;\n            for (let index = 1; index < row.length; index += 1) {\n                if ((row[index] as number) > (row[best] as number)) best = index;\n            }\n            const label = classes[best];\n            if (label === undefined) return best;\n            return numeric ? Number(label) : label;\n        });\n\n        return { labels, probabilities };\n    }\n}\n\n/**\n * Read a source into an `ArrayBuffer`.\n *\n * @param source A URL or the bytes.\n * @param requestInit `fetch` options.\n * @returns The bytes.\n * @throws {@link ModelFetchError} when the URL cannot be read.\n */\nasync function toBuffer(\n    source: string | ArrayBuffer | Uint8Array,\n    requestInit?: RequestInit,\n): Promise<ArrayBuffer> {\n    if (typeof source === \"string\") {\n        let response: Response;\n        try {\n            response = await fetch(source, requestInit);\n        } catch (error) {\n            throw new ModelFetchError(`Could not download the model: ${source}`, {\n                cause: error,\n            });\n        }\n        if (!response.ok) {\n            throw new ModelFetchError(\n                `Could not download the model: ${response.status} ${response.statusText}`,\n            );\n        }\n        return await response.arrayBuffer();\n    }\n    if (source instanceof Uint8Array) {\n        return source.buffer.slice(\n            source.byteOffset,\n            source.byteOffset + source.byteLength,\n        ) as ArrayBuffer;\n    }\n    return source;\n}\n\n/**\n * Check a batch before predicting on it.\n *\n * @param rows The batch.\n * @param expected Features the model wants per row.\n * @returns The batch width.\n * @throws {@link FeatureShapeError} when the batch cannot be predicted on.\n */\nfunction validateRows(rows: readonly FeatureRow[], expected: number): number {\n    if (!Array.isArray(rows) || rows.length === 0) {\n        throw new FeatureShapeError(\"predict() needs at least one row, shaped [[f1, f2, ...]].\");\n    }\n    const width = rows[0]?.length ?? 0;\n    const ragged = rows.findIndex((row) => row.length !== width);\n    if (ragged !== -1) {\n        throw new FeatureShapeError(\n            `All rows must have the same width; row ${ragged} has ` +\n                `${rows[ragged]?.length} values, expected ${width}.`,\n        );\n    }\n    if (width !== expected) {\n        throw new FeatureShapeError(\n            `The model expects ${expected} features per row, got ${width}.`,\n        );\n    }\n    return width;\n}\n"],"mappings":"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"}