nirs4all 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nirs4all contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # JavaScript/WASM Binding
2
+
3
+ npm package name: `nirs4all`
4
+
5
+ This package is the runtime surface that `nirs4all-web` should consume. The web
6
+ application lives in `nirs4all-web`; this directory is for the reusable
7
+ JavaScript/WASM binding and package metadata.
8
+
9
+ The portable execution API delegates Kennard-Stone, SNV, Savitzky-Golay, and
10
+ PLS component sweeps to `@nirs4all/methods-wasm`:
11
+
12
+ - `runPortablePipeline(source, dataset)` parses the shared nirs4all JSON/YAML
13
+ syntax, executes the portable subset, and returns parity-checkable split,
14
+ target, variant, and selected-result fields plus a serialized selected PLS
15
+ model.
16
+ - `predictPortablePipeline(result, dataset)` replays the recorded preprocessing
17
+ chain and predicts with that serialized model through the same methods WASM
18
+ backend.
19
+
20
+ Savitzky-Golay defaults to `mode: "interp"` for full nirs4all parity and
21
+ preserves explicit methods-backed modes (`mirror`, `constant`, `nearest`,
22
+ `wrap`, `interp`) plus `cval` in the serialized preprocessing chain.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "nirs4all",
3
+ "version": "0.1.0",
4
+ "description": "Portable nirs4all aggregate binding for JavaScript and WASM",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.d.ts",
9
+ "default": "./src/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test tests/*.test.js"
19
+ },
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/GBeurier/nirs4all-lite.git",
24
+ "directory": "bindings/wasm"
25
+ },
26
+ "keywords": [
27
+ "nirs",
28
+ "spectroscopy",
29
+ "chemometrics",
30
+ "wasm"
31
+ ],
32
+ "dependencies": {
33
+ "yaml": "^2.9.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@nirs4all/methods-wasm": {
37
+ "optional": true
38
+ },
39
+ "nirs4all-formats-wasm": {
40
+ "optional": true
41
+ },
42
+ "nirs4all-io-wasm": {
43
+ "optional": true
44
+ },
45
+ "@nirs4all/datasets-wasm": {
46
+ "optional": true
47
+ },
48
+ "dag-ml-wasm": {
49
+ "optional": true
50
+ },
51
+ "dag-ml-data-wasm": {
52
+ "optional": true
53
+ }
54
+ },
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "provenance": true
58
+ }
59
+ }
@@ -0,0 +1,398 @@
1
+ import { loadMethodsWasm, loadPipelineDefinition } from './index.js';
2
+
3
+ const KENNARD_STONE = new Set([
4
+ 'nirs4all.operators.splitters.KennardStoneSplitter',
5
+ 'nirs4all.operators.splitters.splitters.KennardStoneSplitter',
6
+ ]);
7
+
8
+ const SNV = new Set([
9
+ 'nirs4all.operators.transforms.SNV',
10
+ 'nirs4all.operators.transforms.StandardNormalVariate',
11
+ 'nirs4all.operators.transforms.scalers.StandardNormalVariate',
12
+ ]);
13
+
14
+ const SAVGOL = new Set([
15
+ 'nirs4all.operators.transforms.SavitzkyGolay',
16
+ 'nirs4all.operators.transforms.nirs.SavitzkyGolay',
17
+ ]);
18
+
19
+ const PLS = new Set([
20
+ 'sklearn.cross_decomposition.PLSRegression',
21
+ 'sklearn.cross_decomposition._pls.PLSRegression',
22
+ ]);
23
+
24
+ export async function runPortablePipeline(source, dataset, options = {}) {
25
+ const definition = loadPipelineDefinition(source);
26
+ const methods = options.methods ?? await loadMethodsWasm();
27
+ if (typeof methods.loadModule === 'function') {
28
+ await methods.loadModule();
29
+ }
30
+
31
+ const input = coerceDataset(dataset);
32
+ const plan = parseExecutionPlan(definition);
33
+ const split = computeSplit(methods, plan.splitter, input);
34
+ const train = selectRows(input.X, input.rows, input.cols, split.trainIndices);
35
+ const test = selectRows(input.X, input.rows, input.cols, split.testIndices);
36
+ const yTrain = selectRows(input.y, input.rows, 1, split.trainIndices);
37
+ const yTest = selectRows(input.y, input.rows, 1, split.testIndices);
38
+
39
+ let XTrain = train;
40
+ let XTest = test;
41
+ const preprocessing = [];
42
+
43
+ for (const step of plan.preprocessing) {
44
+ const op = methods.ppCreate(step.type, step.params);
45
+ try {
46
+ methods.ppFit(op, XTrain.data, XTrain.rows, XTrain.cols);
47
+ XTrain = {
48
+ data: methods.ppTransform(op, XTrain.data, XTrain.rows, XTrain.cols),
49
+ rows: XTrain.rows,
50
+ cols: XTrain.cols,
51
+ };
52
+ XTest = {
53
+ data: methods.ppTransform(op, XTest.data, XTest.rows, XTest.cols),
54
+ rows: XTest.rows,
55
+ cols: XTest.cols,
56
+ };
57
+ preprocessing.push({ type: step.type, params: step.params });
58
+ } finally {
59
+ methods.ppDestroy(op);
60
+ }
61
+ }
62
+
63
+ const candidates = plan.nComponents.map((nComponents) => {
64
+ const model = methods.fitPls(
65
+ { data: XTrain.data, rows: XTrain.rows, cols: XTrain.cols },
66
+ { data: yTrain.data, rows: yTrain.rows, cols: 1 },
67
+ nComponents,
68
+ );
69
+ const predicted = methods.predictPls(model, {
70
+ data: XTest.data,
71
+ rows: XTest.rows,
72
+ cols: XTest.cols,
73
+ });
74
+ const predictions = Array.from(predicted.data);
75
+ const targets = Array.from(yTest.data);
76
+ return {
77
+ n_components: nComponents,
78
+ rmse: rmse(predictions, targets),
79
+ predictions,
80
+ model: serializePlsModel(model, nComponents),
81
+ };
82
+ });
83
+
84
+ const selected = candidates.reduce((best, item) => (item.rmse < best.rmse ? item : best), candidates[0]);
85
+ const variants = candidates.map(stripVariantModel);
86
+
87
+ return {
88
+ name: definition.name,
89
+ rows: input.rows,
90
+ cols: input.cols,
91
+ split,
92
+ preprocessing,
93
+ variants,
94
+ selected: stripVariantModel(selected),
95
+ model: selected.model,
96
+ targets: Array.from(yTest.data),
97
+ };
98
+ }
99
+
100
+ export async function predictPortablePipeline(fitted, dataset, options = {}) {
101
+ if (!fitted || typeof fitted !== 'object') {
102
+ throw new TypeError('Portable prediction requires a fitted portable pipeline result.');
103
+ }
104
+ const methods = options.methods ?? await loadMethodsWasm();
105
+ if (typeof methods.loadModule === 'function') {
106
+ await methods.loadModule();
107
+ }
108
+
109
+ let X = coerceFeatures(dataset);
110
+ for (const step of fitted.preprocessing ?? []) {
111
+ const op = methods.ppCreate(step.type, step.params ?? []);
112
+ try {
113
+ methods.ppFit(op, X.data, X.rows, X.cols);
114
+ X = {
115
+ data: methods.ppTransform(op, X.data, X.rows, X.cols),
116
+ rows: X.rows,
117
+ cols: X.cols,
118
+ };
119
+ } finally {
120
+ methods.ppDestroy(op);
121
+ }
122
+ }
123
+
124
+ const model = hydratePlsModel(fitted.model ?? fitted.selected?.model);
125
+ const predicted = methods.predictPls(model, {
126
+ data: X.data,
127
+ rows: X.rows,
128
+ cols: X.cols,
129
+ });
130
+ return {
131
+ data: Array.from(predicted.data),
132
+ rows: predicted.rows,
133
+ cols: predicted.cols,
134
+ };
135
+ }
136
+
137
+ export function parseExecutionPlan(source) {
138
+ const definition = source && Array.isArray(source.pipeline) ? source : loadPipelineDefinition(source);
139
+ let splitter = null;
140
+ const preprocessing = [];
141
+ let modelStep = null;
142
+
143
+ for (const step of definition.pipeline) {
144
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
145
+ throw new TypeError('Portable pipeline steps must be mapping objects.');
146
+ }
147
+
148
+ if (typeof step.class === 'string') {
149
+ if (KENNARD_STONE.has(step.class)) {
150
+ splitter = { type: 'KennardStone', params: step.params ?? {} };
151
+ } else if (SNV.has(step.class)) {
152
+ preprocessing.push({ type: 'StandardNormalVariate', params: [] });
153
+ } else if (SAVGOL.has(step.class)) {
154
+ preprocessing.push({ type: 'SavitzkyGolay', params: savgolParams(step.params ?? {}) });
155
+ } else {
156
+ throw new Error(`Portable execution does not support step class '${step.class}'.`);
157
+ }
158
+ continue;
159
+ }
160
+
161
+ if (step.model && typeof step.model === 'object') {
162
+ if (modelStep) {
163
+ throw new Error('Portable execution supports exactly one model step.');
164
+ }
165
+ modelStep = step;
166
+ continue;
167
+ }
168
+
169
+ throw new Error(`Portable execution does not support pipeline step: ${JSON.stringify(step)}`);
170
+ }
171
+
172
+ if (!modelStep) {
173
+ throw new Error('Portable execution requires a PLSRegression model step.');
174
+ }
175
+ const model = modelStep.model;
176
+ if (!PLS.has(model.class)) {
177
+ throw new Error(`Portable execution does not support model class '${model.class}'.`);
178
+ }
179
+
180
+ return {
181
+ splitter,
182
+ preprocessing,
183
+ nComponents: componentValues(modelStep),
184
+ };
185
+ }
186
+
187
+ function coerceDataset(dataset) {
188
+ if (!dataset || typeof dataset !== 'object') {
189
+ throw new TypeError('Portable execution requires a dataset object.');
190
+ }
191
+ const rows = Number(dataset.rows ?? dataset.n_samples ?? 0);
192
+ const cols = Number(dataset.cols ?? dataset.n_features ?? 0);
193
+ const X = flattenMatrix(dataset.X, rows, cols, 'X');
194
+ const y = flattenMatrix(dataset.y, rows, 1, 'y');
195
+ return { X, y, rows, cols };
196
+ }
197
+
198
+ function coerceFeatures(dataset) {
199
+ if (!dataset || typeof dataset !== 'object') {
200
+ throw new TypeError('Portable prediction requires a feature dataset object.');
201
+ }
202
+ const rows = Number(dataset.rows ?? dataset.n_samples ?? 0);
203
+ const cols = Number(dataset.cols ?? dataset.n_features ?? 0);
204
+ const X = flattenMatrix(dataset.X, rows, cols, 'X');
205
+ return { data: X, rows, cols };
206
+ }
207
+
208
+ function flattenMatrix(value, rows, cols, label) {
209
+ if (!Number.isInteger(rows) || rows <= 0 || !Number.isInteger(cols) || cols <= 0) {
210
+ throw new TypeError(`Dataset ${label} shape must provide positive integer rows/cols.`);
211
+ }
212
+ if (value instanceof Float64Array) {
213
+ if (value.length !== rows * cols) {
214
+ throw new RangeError(`Dataset ${label} length ${value.length} does not match ${rows}x${cols}.`);
215
+ }
216
+ return value;
217
+ }
218
+ if (Array.isArray(value) && Array.isArray(value[0])) {
219
+ const out = new Float64Array(rows * cols);
220
+ for (let r = 0; r < rows; r += 1) {
221
+ for (let c = 0; c < cols; c += 1) {
222
+ out[r * cols + c] = Number(value[r][c]);
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
228
+ if (value.length !== rows * cols) {
229
+ throw new RangeError(`Dataset ${label} length ${value.length} does not match ${rows}x${cols}.`);
230
+ }
231
+ return Float64Array.from(value);
232
+ }
233
+ throw new TypeError(`Dataset ${label} must be a Float64Array, a flat array, or a nested row array.`);
234
+ }
235
+
236
+ function computeSplit(methods, splitter, input) {
237
+ if (!splitter) {
238
+ const indices = Array.from({ length: input.rows }, (_, i) => i);
239
+ return { kind: 'all', trainIndices: indices, testIndices: indices };
240
+ }
241
+ const splitOptions = { testSize: numberParam(splitter.params.test_size, 0.25) };
242
+ if (typeof methods.computeSplitIndices === 'function') {
243
+ const split = methods.computeSplitIndices(
244
+ 'KennardStone',
245
+ { data: input.X, rows: input.rows, cols: input.cols },
246
+ null,
247
+ splitOptions,
248
+ );
249
+ return {
250
+ kind: 'KennardStone',
251
+ trainIndices: Array.from(split.trainIndices),
252
+ testIndices: Array.from(split.testIndices),
253
+ };
254
+ }
255
+ const mask = methods.computeSplit(
256
+ 'KennardStone',
257
+ { data: input.X, rows: input.rows, cols: input.cols },
258
+ { data: input.y, rows: input.rows, cols: 1 },
259
+ splitOptions,
260
+ );
261
+ const trainIndices = [];
262
+ const testIndices = [];
263
+ for (let i = 0; i < mask.length; i += 1) {
264
+ if (mask[i]) testIndices.push(i);
265
+ else trainIndices.push(i);
266
+ }
267
+ return { kind: 'KennardStone', trainIndices, testIndices };
268
+ }
269
+
270
+ function selectRows(data, rows, cols, indices) {
271
+ const out = new Float64Array(indices.length * cols);
272
+ for (let r = 0; r < indices.length; r += 1) {
273
+ const source = indices[r];
274
+ if (source < 0 || source >= rows) {
275
+ throw new RangeError(`Row index ${source} is outside 0..${rows - 1}.`);
276
+ }
277
+ out.set(data.subarray(source * cols, source * cols + cols), r * cols);
278
+ }
279
+ return { data: out, rows: indices.length, cols };
280
+ }
281
+
282
+ function savgolParams(params) {
283
+ const delta = numberParam(params.delta, 1);
284
+ if (delta !== 1) {
285
+ throw new Error('Portable Savitzky-Golay execution currently supports delta=1 only.');
286
+ }
287
+ return [
288
+ numberParam(params.window_length ?? params.window, 11),
289
+ numberParam(params.polyorder, 3),
290
+ numberParam(params.deriv, 0),
291
+ // scipy.signal.savgol_filter, and therefore nirs4all Python, default to interp.
292
+ savgolMode(params.mode ?? 'interp'),
293
+ numberParam(params.cval, 0),
294
+ ];
295
+ }
296
+
297
+ const SAVGOL_MODES = new Map([
298
+ ['mirror', 0],
299
+ ['constant', 1],
300
+ ['nearest', 2],
301
+ ['wrap', 3],
302
+ ['interp', 4],
303
+ ]);
304
+
305
+ function savgolMode(value) {
306
+ if (typeof value === 'string') {
307
+ const mode = SAVGOL_MODES.get(value.toLowerCase());
308
+ if (mode != null) {
309
+ return mode;
310
+ }
311
+ throw new Error(`Unsupported Savitzky-Golay mode: ${value}`);
312
+ }
313
+ const mode = Number(value);
314
+ if (Number.isInteger(mode) && mode >= 0 && mode <= 4) {
315
+ return mode;
316
+ }
317
+ throw new Error(`Unsupported Savitzky-Golay mode: ${value}`);
318
+ }
319
+
320
+ function componentValues(step) {
321
+ if (Array.isArray(step._range_)) {
322
+ if (step.param !== 'n_components') {
323
+ throw new Error("Portable execution only supports _range_ sweeps over 'n_components'.");
324
+ }
325
+ const [start, stop, stride] = step._range_.map(Number);
326
+ if (![start, stop, stride].every(Number.isFinite) || stride <= 0 || start > stop) {
327
+ throw new Error('Invalid n_components _range_; expected [start, stop, positive_step].');
328
+ }
329
+ const values = [];
330
+ for (let value = start; value <= stop; value += stride) {
331
+ values.push(Math.round(value));
332
+ }
333
+ return values;
334
+ }
335
+ const params = step.model?.params ?? {};
336
+ return [Math.max(1, Math.round(numberParam(params.n_components, 2)))];
337
+ }
338
+
339
+ function numberParam(value, fallback) {
340
+ const n = Number(value);
341
+ return Number.isFinite(n) ? n : fallback;
342
+ }
343
+
344
+ function rmse(predictions, targets) {
345
+ if (predictions.length !== targets.length) {
346
+ throw new RangeError('Prediction/target length mismatch.');
347
+ }
348
+ let sum = 0;
349
+ for (let i = 0; i < predictions.length; i += 1) {
350
+ const diff = predictions[i] - targets[i];
351
+ sum += diff * diff;
352
+ }
353
+ return Math.sqrt(sum / predictions.length);
354
+ }
355
+
356
+ function stripVariantModel(variant) {
357
+ return {
358
+ n_components: variant.n_components,
359
+ rmse: variant.rmse,
360
+ predictions: variant.predictions,
361
+ };
362
+ }
363
+
364
+ function serializePlsModel(model, nComponents) {
365
+ if (!model || typeof model !== 'object') {
366
+ throw new TypeError('nirs4all-methods returned an invalid PLS model.');
367
+ }
368
+ return {
369
+ type: 'PLSRegression',
370
+ n_components: nComponents,
371
+ coefficients: serializeVector(model.coefficients),
372
+ xMean: serializeVector(model.xMean),
373
+ yMean: serializeVector(model.yMean),
374
+ intercept: model.intercept == null ? null : serializeVector(model.intercept),
375
+ n_features: Number(model.n_features),
376
+ n_targets: Number(model.n_targets),
377
+ };
378
+ }
379
+
380
+ function hydratePlsModel(model) {
381
+ if (!model || typeof model !== 'object') {
382
+ throw new TypeError('Portable prediction requires a serialized PLS model.');
383
+ }
384
+ return {
385
+ coefficients: Float64Array.from(model.coefficients ?? []),
386
+ xMean: Float64Array.from(model.xMean ?? model.x_mean ?? []),
387
+ yMean: Float64Array.from(model.yMean ?? model.y_mean ?? []),
388
+ intercept: model.intercept == null ? null : Float64Array.from(model.intercept),
389
+ n_features: Number(model.n_features),
390
+ n_targets: Number(model.n_targets),
391
+ };
392
+ }
393
+
394
+ function serializeVector(value) {
395
+ if (value == null) return [];
396
+ if (typeof value === 'number') return [value];
397
+ return Array.from(value);
398
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ export interface Upstream {
2
+ key: 'dag_ml' | 'dag_ml_data' | 'formats' | 'io' | 'datasets' | 'methods';
3
+ candidates: readonly string[];
4
+ role: string;
5
+ }
6
+
7
+ export interface UpstreamProxy {
8
+ key: Upstream['key'];
9
+ import(): Promise<unknown>;
10
+ }
11
+
12
+ export interface PipelineDefinition {
13
+ name: string;
14
+ description: string;
15
+ random_state?: number;
16
+ pipeline: unknown[];
17
+ }
18
+
19
+ export interface PortableMatrixDataset {
20
+ X: Float64Array | number[] | readonly number[] | readonly (readonly number[])[];
21
+ y: Float64Array | number[] | readonly number[] | readonly (readonly number[])[];
22
+ rows?: number;
23
+ cols?: number;
24
+ n_samples?: number;
25
+ n_features?: number;
26
+ }
27
+
28
+ export interface PortableSplitResult {
29
+ kind: 'all' | 'KennardStone';
30
+ trainIndices: number[];
31
+ testIndices: number[];
32
+ }
33
+
34
+ export interface PortableVariantResult {
35
+ n_components: number;
36
+ rmse: number;
37
+ predictions: number[];
38
+ }
39
+
40
+ export interface PortablePlsModel {
41
+ type: 'PLSRegression';
42
+ n_components: number;
43
+ coefficients: number[];
44
+ xMean: number[];
45
+ yMean: number[];
46
+ intercept: number[] | null;
47
+ n_features: number;
48
+ n_targets: number;
49
+ }
50
+
51
+ export interface PortableExecutionResult {
52
+ name: string;
53
+ rows: number;
54
+ cols: number;
55
+ split: PortableSplitResult;
56
+ preprocessing: { type: string; params: number[] }[];
57
+ variants: PortableVariantResult[];
58
+ selected: PortableVariantResult;
59
+ model: PortablePlsModel;
60
+ targets: number[];
61
+ }
62
+
63
+ export interface PortablePredictionResult {
64
+ data: number[];
65
+ rows: number;
66
+ cols: number;
67
+ }
68
+
69
+ export const upstreams: readonly Upstream[];
70
+ export const portableOperatorClasses: readonly string[];
71
+
72
+ export function upstream(name: string): Upstream | null;
73
+ export function importUpstream(name: string): Promise<unknown>;
74
+ export function loadFormats(): Promise<unknown>;
75
+ export function loadIo(): Promise<unknown>;
76
+ export function loadDatasets(): Promise<unknown>;
77
+ export function loadMethods(): Promise<unknown>;
78
+ export function loadDagMl(): Promise<unknown>;
79
+ export function loadDagMlData(): Promise<unknown>;
80
+ export function loadPortableStack(keys?: readonly string[]): Promise<Record<string, unknown>>;
81
+ export function loadMethodsWasm(): Promise<unknown>;
82
+ export function methodsWasm(): unknown;
83
+ export function loadDagMlWasm(): Promise<unknown>;
84
+ export function loadDagMlDataWasm(): Promise<unknown>;
85
+ export function loadDatasetsWasm(): Promise<unknown>;
86
+ export function loadDataIoWasm(): Promise<{ formats: unknown; io: unknown }>;
87
+
88
+ export const formats: UpstreamProxy;
89
+ export const io: UpstreamProxy;
90
+ export const datasets: UpstreamProxy;
91
+ export const methods: UpstreamProxy;
92
+ export const dagMl: UpstreamProxy;
93
+ export const dagMlData: UpstreamProxy;
94
+
95
+ export function loadPipelineDefinition(source: string | unknown[] | Record<string, unknown>): PipelineDefinition;
96
+ export function portableClassNames(definition: PipelineDefinition | unknown[] | Record<string, unknown>): string[];
97
+ export function parseExecutionPlan(source: string | PipelineDefinition | unknown[] | Record<string, unknown>): {
98
+ splitter: { type: 'KennardStone'; params: Record<string, unknown> } | null;
99
+ preprocessing: { type: 'StandardNormalVariate' | 'SavitzkyGolay'; params: number[] }[];
100
+ nComponents: number[];
101
+ };
102
+ export function runPortablePipeline(
103
+ source: string | PipelineDefinition | unknown[] | Record<string, unknown>,
104
+ dataset: PortableMatrixDataset,
105
+ options?: { methods?: unknown },
106
+ ): Promise<PortableExecutionResult>;
107
+ export function predictPortablePipeline(
108
+ fitted: PortableExecutionResult | { preprocessing?: { type: string; params: number[] }[]; model?: PortablePlsModel },
109
+ dataset: Omit<PortableMatrixDataset, 'y'>,
110
+ options?: { methods?: unknown },
111
+ ): Promise<PortablePredictionResult>;
package/src/index.js ADDED
@@ -0,0 +1,280 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+
3
+ export const upstreams = Object.freeze([
4
+ {
5
+ key: 'dag_ml',
6
+ candidates: ['dag-ml-wasm'],
7
+ role: 'Leakage-safe DAG/ML execution coordinator',
8
+ },
9
+ {
10
+ key: 'dag_ml_data',
11
+ candidates: ['dag-ml-data-wasm'],
12
+ role: 'Sample-aligned data contracts for DAG/ML runtimes',
13
+ },
14
+ {
15
+ key: 'formats',
16
+ candidates: ['nirs4all-formats-wasm'],
17
+ role: 'Spectroscopy/NIRS vendor file readers',
18
+ },
19
+ {
20
+ key: 'io',
21
+ candidates: ['nirs4all-io-wasm'],
22
+ role: 'Dataset assembly bridge',
23
+ },
24
+ {
25
+ key: 'datasets',
26
+ candidates: ['@nirs4all/datasets-wasm'],
27
+ role: 'DOI-pinned NIRS dataset catalog',
28
+ },
29
+ {
30
+ key: 'methods',
31
+ candidates: ['@nirs4all/methods-wasm'],
32
+ role: 'Portable C ABI PLS/NIRS numerical engine',
33
+ },
34
+ ]);
35
+
36
+ export const portableOperatorClasses = Object.freeze([
37
+ 'nirs4all.operators.splitters.KennardStoneSplitter',
38
+ 'nirs4all.operators.splitters.splitters.KennardStoneSplitter',
39
+ 'nirs4all.operators.transforms.SNV',
40
+ 'nirs4all.operators.transforms.StandardNormalVariate',
41
+ 'nirs4all.operators.transforms.scalers.StandardNormalVariate',
42
+ 'nirs4all.operators.transforms.SavitzkyGolay',
43
+ 'nirs4all.operators.transforms.nirs.SavitzkyGolay',
44
+ 'sklearn.cross_decomposition.PLSRegression',
45
+ 'sklearn.cross_decomposition._pls.PLSRegression',
46
+ ]);
47
+
48
+ const portableOperatorSet = new Set(portableOperatorClasses);
49
+
50
+ const upstreamByKey = new Map(upstreams.map((item) => [item.key, item]));
51
+
52
+ export function upstream(name) {
53
+ return upstreamByKey.get(name) ?? null;
54
+ }
55
+
56
+ export async function importUpstream(name) {
57
+ const item = upstream(name);
58
+ if (!item) {
59
+ throw new Error(`Unknown nirs4all upstream: ${name}`);
60
+ }
61
+
62
+ for (const candidate of item.candidates) {
63
+ try {
64
+ return await importUpstreamCandidate(candidate);
65
+ } catch (error) {
66
+ if (isMissingModuleError(error)) {
67
+ continue;
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ throw new Error(
74
+ `nirs4all upstream '${name}' is not installed. Tried ${item.candidates.join(', ')}.`,
75
+ );
76
+ }
77
+
78
+ export const formats = Object.freeze({ key: 'formats', import: () => importUpstream('formats') });
79
+ export const io = Object.freeze({ key: 'io', import: () => importUpstream('io') });
80
+ export const datasets = Object.freeze({ key: 'datasets', import: () => importUpstream('datasets') });
81
+ export const methods = Object.freeze({ key: 'methods', import: () => importUpstream('methods') });
82
+ export const dagMl = Object.freeze({ key: 'dag_ml', import: () => importUpstream('dag_ml') });
83
+ export const dagMlData = Object.freeze({
84
+ key: 'dag_ml_data',
85
+ import: () => importUpstream('dag_ml_data'),
86
+ });
87
+
88
+ export const loadFormats = () => importUpstream('formats');
89
+ export const loadIo = () => importUpstream('io');
90
+ export const loadDatasets = () => importUpstream('datasets');
91
+ export const loadMethods = () => importUpstream('methods');
92
+ export const loadDagMl = () => importUpstream('dag_ml');
93
+ export const loadDagMlData = () => importUpstream('dag_ml_data');
94
+
95
+ let methodsPromise = null;
96
+ let methodsModule = null;
97
+
98
+ export async function loadMethodsWasm() {
99
+ if (!methodsPromise) {
100
+ methodsPromise = (async () => {
101
+ const mod = await loadMethods();
102
+ if (typeof mod.loadModule === 'function') {
103
+ await mod.loadModule();
104
+ }
105
+ methodsModule = mod;
106
+ return mod;
107
+ })();
108
+ }
109
+ return methodsPromise;
110
+ }
111
+
112
+ export function methodsWasm() {
113
+ if (!methodsModule) {
114
+ throw new Error('nirs4all methods WASM is not loaded; call loadMethodsWasm() first.');
115
+ }
116
+ return methodsModule;
117
+ }
118
+
119
+ function initializedWasmLoader(load, init) {
120
+ let promise = null;
121
+ return async () => {
122
+ if (!promise) {
123
+ promise = (async () => {
124
+ const mod = await load();
125
+ if (typeof init === 'function') {
126
+ await init(mod);
127
+ } else if (typeof mod.default === 'function') {
128
+ await mod.default();
129
+ }
130
+ return mod;
131
+ })();
132
+ }
133
+ return promise;
134
+ };
135
+ }
136
+
137
+ export const loadDagMlWasm = initializedWasmLoader(loadDagMl);
138
+ export const loadDagMlDataWasm = initializedWasmLoader(loadDagMlData);
139
+ export const loadDatasetsWasm = initializedWasmLoader(loadDatasets);
140
+
141
+ export async function loadDataIoWasm() {
142
+ const [formatsMod, ioMod] = await Promise.all([
143
+ initializedFormatsWasm(),
144
+ initializedIoWasm(),
145
+ ]);
146
+ return { formats: formatsMod, io: ioMod };
147
+ }
148
+
149
+ const initializedFormatsWasm = initializedWasmLoader(loadFormats);
150
+ const initializedIoWasm = initializedWasmLoader(loadIo);
151
+
152
+ export async function loadPortableStack(keys = upstreams.map((item) => item.key)) {
153
+ const loaded = {};
154
+ for (const key of keys) {
155
+ loaded[key] = await importUpstream(key);
156
+ }
157
+ return loaded;
158
+ }
159
+
160
+ export function loadPipelineDefinition(source) {
161
+ const data = typeof source === 'string' ? parsePipelineText(source) : clone(source);
162
+ const normalized = normalizePipelineRoot(data);
163
+ const pipeline = normalized.pipeline;
164
+ if (!Array.isArray(pipeline)) {
165
+ throw new Error("Pipeline definition key 'pipeline' or 'steps' must contain an array of steps.");
166
+ }
167
+
168
+ const definition = {
169
+ name: String(normalized.name || 'pipeline'),
170
+ description: String(normalized.description || ''),
171
+ pipeline: stripComments(pipeline),
172
+ };
173
+ if (Number.isInteger(normalized.random_state)) {
174
+ definition.random_state = normalized.random_state;
175
+ }
176
+
177
+ const unsupported = portableClassNames(definition).filter((name) => !portableOperatorSet.has(name));
178
+ if (unsupported.length > 0) {
179
+ throw new Error(
180
+ `Pipeline uses operators outside the current nirs4all-lite portable subset: ${[...new Set(unsupported)].join(', ')}`,
181
+ );
182
+ }
183
+
184
+ return definition;
185
+ }
186
+
187
+ export function portableClassNames(definition) {
188
+ const root = definition && Array.isArray(definition.pipeline) ? definition.pipeline : definition;
189
+ const classes = [];
190
+ collectClasses(root, classes);
191
+ return classes;
192
+ }
193
+
194
+ function isMissingModuleError(error) {
195
+ return error && (error.code === 'ERR_MODULE_NOT_FOUND' || error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED');
196
+ }
197
+
198
+ function importUpstreamCandidate(candidate) {
199
+ switch (candidate) {
200
+ case 'dag-ml-wasm':
201
+ return import('dag-ml-wasm');
202
+ case 'dag-ml-data-wasm':
203
+ return import('dag-ml-data-wasm');
204
+ case 'nirs4all-formats-wasm':
205
+ return import('nirs4all-formats-wasm');
206
+ case 'nirs4all-io-wasm':
207
+ return import('nirs4all-io-wasm');
208
+ case '@nirs4all/datasets-wasm':
209
+ return import('@nirs4all/datasets-wasm');
210
+ case '@nirs4all/methods-wasm':
211
+ return import('@nirs4all/methods-wasm');
212
+ default:
213
+ return import(candidate);
214
+ }
215
+ }
216
+
217
+ function parsePipelineText(text) {
218
+ try {
219
+ return JSON.parse(text);
220
+ } catch {
221
+ return parseYaml(text);
222
+ }
223
+ }
224
+
225
+ function clone(value) {
226
+ return value == null ? value : JSON.parse(JSON.stringify(value));
227
+ }
228
+
229
+ function normalizePipelineRoot(data) {
230
+ if (Array.isArray(data)) {
231
+ return { pipeline: data };
232
+ }
233
+ if (!data || typeof data !== 'object') {
234
+ throw new TypeError("Pipeline definition must be an array or an object with a 'pipeline'/'steps' key.");
235
+ }
236
+ if (data.pipeline !== undefined) {
237
+ return data;
238
+ }
239
+ if (data.steps !== undefined) {
240
+ return { ...data, pipeline: data.steps };
241
+ }
242
+ throw new Error("Invalid pipeline definition format. Expected an array or an object with a 'pipeline' or 'steps' key.");
243
+ }
244
+
245
+ function stripComments(value) {
246
+ if (Array.isArray(value)) {
247
+ return value.filter((item) => !isCommentStep(item)).map(stripComments);
248
+ }
249
+ if (value && typeof value === 'object') {
250
+ return Object.fromEntries(
251
+ Object.entries(value)
252
+ .filter(([key]) => key !== '_comment')
253
+ .map(([key, item]) => [key, stripComments(item)]),
254
+ );
255
+ }
256
+ return value;
257
+ }
258
+
259
+ function isCommentStep(value) {
260
+ return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1 && value._comment !== undefined;
261
+ }
262
+
263
+ function collectClasses(value, output) {
264
+ if (Array.isArray(value)) {
265
+ for (const item of value) {
266
+ collectClasses(item, output);
267
+ }
268
+ return;
269
+ }
270
+ if (value && typeof value === 'object') {
271
+ if (typeof value.class === 'string') {
272
+ output.push(value.class);
273
+ }
274
+ for (const item of Object.values(value)) {
275
+ collectClasses(item, output);
276
+ }
277
+ }
278
+ }
279
+
280
+ export { parseExecutionPlan, predictPortablePipeline, runPortablePipeline } from './execution.js';