{"version":3,"file":"predictor.cjs","names":[],"sources":["../../src/tabular/predictor.ts"],"sourcesContent":["/**\n * Running a scikit-learn model in the browser, offline.\n *\n * The model file is produced by `tempest-fastapi-sdk`'s\n * `export_sklearn_to_onnx`. This is everything between that file and an\n * answer — the same glue the Python `OnnxPredictor` provides on a device,\n * with the browser's own traps handled:\n *\n * - **int64 labels arrive as `bigint`.** ONNX Runtime Web surfaces the\n *   label tensor as a `BigInt64Array`, so a caller comparing `label === 1`\n *   silently gets `false` and `JSON.stringify` throws. Labels are converted.\n * - **`ai.onnx.ml` needs the right build.** Measured: importing\n *   `onnxruntime-web/webgpu` loads a WebAssembly binary without those\n *   operators, and session creation fails with `No Op registered for\n *   TreeEnsembleClassifier`. That failure is translated into an error that\n *   names the import.\n * - **Which output is which.** A classifier returns `label` and\n *   `probabilities`; a regressor returns a single `variable`. Indexing by\n *   position works until the day you deploy the other kind.\n */\n\nimport type * as ort from \"onnxruntime-web\";\n\nimport { configuredOrtAssetPath } from \"./assets\";\nimport {\n    FeatureShapeError,\n    InferenceError,\n    ModelLoadError,\n    UnsupportedGraphError,\n} from \"./exceptions\";\nimport type {\n    FeatureRow,\n    PredictedLabel,\n    TabularModelSource,\n    TabularPrediction,\n    TabularPredictorInfo,\n    TabularPredictorOptions,\n} from \"./types\";\n\n/**\n * Import ONNX Runtime Web, only when a model is actually being loaded.\n *\n * Static import would make every consumer of this subpath install the peer,\n * including apps that only ever touch `CompactPredictor` — whose whole\n * point is not needing a runtime. Found by installing the published\n * package into an empty project, which is the only place the difference\n * shows.\n *\n * @returns The runtime module.\n * @throws {@link ModelLoadError} when the peer is not installed, naming it.\n */\nasync function loadRuntime(): Promise<typeof ort> {\n    try {\n        return (await import(\"onnxruntime-web\")) as typeof ort;\n    } catch (error) {\n        throw new ModelLoadError(\n            \"The ONNX route needs the optional peer dependency: \" +\n                \"npm install onnxruntime-web. For a model with no runtime at \" +\n                \"all, export it with edge_pipeline(compact=True) and load it \" +\n                \"through CompactPredictor.\",\n            { cause: error },\n        );\n    }\n}\n\n/**\n * Execution providers used when the caller does not choose.\n *\n * WebAssembly only, and deliberately: scikit-learn graphs are `ai.onnx.ml`\n * operators, which the WebGPU backend does not implement. There is no\n * speed left on the table here — a 10-tree forest predicts a row in about\n * 0.05 ms in Chromium.\n */\nexport const DEFAULT_TABULAR_PROVIDERS: readonly string[] = [\"wasm\"];\n\n/** Output names that indicate predicted classes rather than scores. */\nconst LABEL_HINTS = [\"label\", \"class\", \"variable\", \"output\"] as const;\n\n/** Output names that indicate class scores. */\nconst PROBABILITY_HINTS = [\"probabilit\", \"score\"] as const;\n\n/** Largest plausible feature count; anything above is a dynamic-dim sentinel. */\nconst MAX_DECLARED_FEATURES = 1_000_000;\n\n/**\n * Pick the first output whose name contains one of `hints`.\n *\n * @param names Graph output names.\n * @param hints Lowercase substrings to look for.\n * @returns The matching name, or `null`.\n */\nfunction matchOutput(names: readonly string[], hints: readonly string[]): string | null {\n    for (const hint of hints) {\n        const found = names.find((name) => name.toLowerCase().includes(hint));\n        if (found !== undefined) return found;\n    }\n    return null;\n}\n\n/**\n * Read the declared feature count from the input metadata.\n *\n * A dynamic batch dimension is reported as a symbolic string or as an\n * out-of-range number (`4294967295` — an unsigned `-1`), so only a sane\n * positive integer in the second position is trusted.\n *\n * @param session The loaded session.\n * @returns The feature count, or `null` when the graph does not declare one.\n */\nfunction declaredFeatures(session: ort.InferenceSession): number | null {\n    const metadata = session.inputMetadata?.[0];\n    if (metadata === undefined || metadata.isTensor !== true) return null;\n    const dimension = metadata.shape[1];\n    if (typeof dimension !== \"number\") return null;\n    if (!Number.isInteger(dimension) || dimension <= 0) return null;\n    return dimension > MAX_DECLARED_FEATURES ? null : dimension;\n}\n\n/**\n * Convert one raw label value into a JS-friendly label.\n *\n * @param value A tensor element: `bigint` for int64, `number` for float,\n *   `string` for a string-labelled classifier.\n * @returns The label as a number or string.\n */\nfunction toLabel(value: unknown): PredictedLabel {\n    if (typeof value === \"bigint\") return Number(value);\n    if (typeof value === \"number\") return value;\n    return String(value);\n}\n\n/**\n * Translate a session-creation failure into an error naming its cause.\n *\n * @param error Whatever ONNX Runtime threw.\n * @returns The error to surface.\n */\nfunction asLoadError(error: unknown): Error {\n    const message = error instanceof Error ? error.message : String(error);\n    if (message.includes(\"No Op registered\")) {\n        return new UnsupportedGraphError(\n            \"This runtime build has no kernels for the model's operators. \" +\n                \"scikit-learn exports use the ai.onnx.ml domain, which is missing \" +\n                'from the WebGPU build: import \"onnxruntime-web\", not ' +\n                '\"onnxruntime-web/webgpu\". Original error: ' +\n                message,\n            { cause: error },\n        );\n    }\n    return new ModelLoadError(`Failed to load the model: ${message}`, { cause: error });\n}\n\n/**\n * Translate a run failure into an error naming its cause.\n *\n * Measured: an export made with skl2onnx's default (ZipMap enabled) has a\n * probability output that is a sequence of maps, and ONNX Runtime Web\n * refuses to read non-tensor values — `Reading data from non-tensor typed\n * value is not supported`. That message describes the runtime's limitation,\n * not the fix, so it is replaced by one that names the export flag.\n *\n * @param error Whatever ONNX Runtime threw.\n * @returns The error to surface.\n */\nfunction asRunError(error: unknown): Error {\n    const message = error instanceof Error ? error.message : String(error);\n    if (\n        message.includes(\"non-tensor typed value\") ||\n        message.includes(\"Can't access output tensor data\")\n    ) {\n        return new InferenceError(\n            \"The model has a non-tensor output, which ONNX Runtime Web cannot \" +\n                \"read. A scikit-learn export made with ZipMap enabled returns a \" +\n                \"sequence of maps per row — re-export with export_sklearn_to_onnx, \" +\n                `which disables it. Original error: ${message}`,\n            { cause: error },\n        );\n    }\n    return new InferenceError(`Inference failed: ${message}`, { cause: error });\n}\n\n/**\n * A loaded tabular model, ready to answer.\n *\n * @example\n * ```ts\n * const predictor = await TabularPredictor.create(\"/models/classifier.onnx\");\n * const { labels, probabilities } = await predictor.predict([[5.1, 3.5, 1.4, 0.2]]);\n * ```\n */\nexport class TabularPredictor {\n    private constructor(\n        private readonly runtime: typeof ort,\n        private readonly session: ort.InferenceSession,\n        /** What is loaded and how it is configured. */\n        public readonly info: TabularPredictorInfo,\n    ) {}\n\n    /**\n     * Load a model and describe its graph.\n     *\n     * @param source A URL string, or the model bytes (which is what an\n     *   offline app passes, having read them from the cache).\n     * @param options Providers, warm-up and pass-through session options.\n     * @throws {@link UnsupportedGraphError} when the runtime build lacks the\n     *   `ai.onnx.ml` operators — the WebGPU entry point does.\n     * @throws {@link ModelLoadError} for any other load failure.\n     */\n    static async create(\n        source: TabularModelSource,\n        options: TabularPredictorOptions = {},\n    ): Promise<TabularPredictor> {\n        const providers = options.providers ?? DEFAULT_TABULAR_PROVIDERS;\n        const runtime = await loadRuntime();\n        const assets = configuredOrtAssetPath();\n        if (assets !== undefined) runtime.env.wasm.wasmPaths = assets;\n\n        let session: ort.InferenceSession;\n        try {\n            session = await runtime.InferenceSession.create(source as never, {\n                ...(options.sessionOptions ?? {}),\n                executionProviders:\n                    providers as ort.InferenceSession.SessionOptions[\"executionProviders\"],\n            });\n        } catch (error) {\n            throw asLoadError(error);\n        }\n\n        const outputNames = [...session.outputNames];\n        const probabilityOutput = matchOutput(outputNames, PROBABILITY_HINTS);\n        const labelOutput =\n            outputNames.find((name) => name !== probabilityOutput && isLabelName(name)) ??\n            outputNames.find((name) => name !== probabilityOutput) ??\n            (outputNames[0] as string);\n\n        const predictor = new TabularPredictor(runtime, session, {\n            inputName: session.inputNames[0] as string,\n            numFeatures: declaredFeatures(session),\n            outputNames,\n            labelOutput,\n            probabilityOutput,\n            isClassifier: probabilityOutput !== null,\n            providers,\n        });\n\n        if (options.warmup !== false) await predictor.warmUp();\n        return predictor;\n    }\n\n    /**\n     * Run one throwaway inference so the first real call is not the slow one.\n     *\n     * Skipped when the graph does not declare a feature count, since there\n     * is no shape to synthesise.\n     *\n     * @tempest-limits empty-catch — a warm-up that cannot run is not a reason to\n     * refuse to serve. The synthetic all-zero row can be rejected by a graph that\n     * expects a different dtype or a categorical encoding, and that says nothing\n     * about the real rows the caller will send; the only cost of the failure is\n     * that the first real inference pays the lazy-init it would have paid anyway.\n     */\n    async warmUp(): Promise<void> {\n        const features = this.info.numFeatures;\n        if (features === null) return;\n        try {\n            await this.predict([new Array<number>(features).fill(0)]);\n        } catch {\n            /* empty */\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 produces them, and the\n     *   call's duration.\n     * @throws {@link FeatureShapeError} when the batch is empty, ragged, or\n     *   the wrong width — checked here so the failure names the mismatch\n     *   instead of surfacing as an opaque runtime error.\n     * @throws {@link InferenceError} when the session runs but its outputs\n     *   cannot be read.\n     */\n    async predict(rows: readonly FeatureRow[]): Promise<TabularPrediction> {\n        if (!Array.isArray(rows) || rows.length === 0) {\n            throw new FeatureShapeError(\n                \"predict() needs at least one row, shaped [[f1, f2, ...]].\",\n            );\n        }\n        const width = rows[0]?.length ?? 0;\n        if (width === 0) {\n            throw new FeatureShapeError(\"The first row has no feature values.\");\n        }\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        const expected = this.info.numFeatures;\n        if (expected !== null && width !== expected) {\n            throw new FeatureShapeError(\n                `The model expects ${expected} features per row, got ${width}.`,\n            );\n        }\n\n        const flat = new Float32Array(rows.length * width);\n        for (let index = 0; index < rows.length; index += 1) {\n            flat.set(rows[index] as number[], index * width);\n        }\n        const tensor = new this.runtime.Tensor(\"float32\", flat, [rows.length, width]);\n\n        const started = performance.now();\n        let outputs: ort.InferenceSession.OnnxValueMapType;\n        try {\n            outputs = await this.session.run({ [this.info.inputName]: tensor });\n        } catch (error) {\n            throw asRunError(error);\n        }\n        const ms = performance.now() - started;\n\n        const labelTensor = outputs[this.info.labelOutput];\n        if (labelTensor?.data === undefined) {\n            throw new InferenceError(\n                `The model produced no readable \"${this.info.labelOutput}\" output.`,\n            );\n        }\n\n        const labels: PredictedLabel[] = Array.from(\n            labelTensor.data as ArrayLike<unknown>,\n            toLabel,\n        );\n\n        const probabilities: number[][] = [];\n        if (this.info.probabilityOutput !== null) {\n            const scores = outputs[this.info.probabilityOutput];\n            if (scores?.data !== undefined) {\n                const values = Array.from(scores.data as ArrayLike<number>, Number);\n                const classes = values.length / rows.length;\n                for (let index = 0; index < rows.length; index += 1) {\n                    probabilities.push(values.slice(index * classes, (index + 1) * classes));\n                }\n            }\n        }\n\n        return { labels, probabilities, numRows: rows.length, ms };\n    }\n\n    /**\n     * Release the session's memory.\n     *\n     * Worth calling on a route that swaps models: the WebAssembly heap does\n     * not shrink on garbage collection alone.\n     */\n    async dispose(): Promise<void> {\n        await this.session.release?.();\n    }\n}\n\n/**\n * Whether an output name looks like a label rather than a score.\n *\n * @param name The graph output name.\n * @returns `true` when the name matches a known label convention.\n */\nfunction isLabelName(name: string): boolean {\n    const lowered = name.toLowerCase();\n    return LABEL_HINTS.some((hint) => lowered.includes(hint));\n}\n"],"mappings":"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"}