memlnaut-nisps/tests/e2e/wasm-api.spec.js

232 lines
8.4 KiB
JavaScript
Raw Normal View History

/**
* WASM API tests verify new WasmIML methods:
* - inferBatch: batch inference across multiple input points
* - evalLoss: MSE loss evaluation without training
* - getLayerStats: per-layer weight statistics
* - lossHistory: full per-iteration loss curve after training
* - moveWeights with pin mask: pinned outputs stay unchanged
*/
const { test, expect } = require('@playwright/test');
const { loadApp } = require('./helpers');
const EXAMPLE_LOW = { input: [0.1, 0.9], output: new Array(126).fill(0.1) };
const EXAMPLE_HIGH = { input: [0.9, 0.1], output: new Array(126).fill(0.9) };
test.describe('inferBatch', () => {
test('returns correct count of output arrays, each length 126', async ({ page }) => {
await loadApp(page);
const result = await page.evaluate(() => {
const outputs = window.__nisps.iml.inferBatch([[0.2, 0.8], [0.5, 0.5], [0.9, 0.1]]);
return outputs.map(o => Array.from(o));
});
expect(result).toHaveLength(3);
for (const arr of result) {
expect(arr).toHaveLength(126);
}
});
test('matches individual inference within 1e-5', async ({ page }) => {
await loadApp(page);
const points = [[0.1, 0.2], [0.3, 0.7], [0.5, 0.5], [0.8, 0.1], [0.0, 1.0]];
const { batchResults, individualResults } = await page.evaluate((pts) => {
const iml = window.__nisps.iml;
const batch = iml.inferBatch(pts).map(o => Array.from(o));
const individual = pts.map(([x, y]) => {
iml.setInput(0, x);
iml.setInput(1, y);
iml.process();
return Array.from(iml.getOutputs());
});
return { batchResults: batch, individualResults: individual };
}, points);
expect(batchResults).toHaveLength(5);
for (let i = 0; i < 5; i++) {
for (let j = 0; j < 126; j++) {
expect(Math.abs(batchResults[i][j] - individualResults[i][j])).toBeLessThan(1e-5);
}
}
});
test('all batch outputs are in [0, 1]', async ({ page }) => {
await loadApp(page);
const corners = [
[0, 0], [0, 1], [1, 0], [1, 1],
[0.5, 0], [0.5, 1], [0, 0.5], [1, 0.5],
[0.25, 0.75], [0.75, 0.25],
];
const results = await page.evaluate((pts) => {
return window.__nisps.iml.inferBatch(pts).map(o => Array.from(o));
}, corners);
expect(results).toHaveLength(10);
for (const arr of results) {
for (const v of arr) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
}
});
});
test.describe('evalLoss', () => {
test('returns null when no examples exist', async ({ page }) => {
await loadApp(page);
const loss = await page.evaluate(() => window.__nisps.iml.evalLoss());
expect(loss).toBeNull();
});
test('returns finite non-negative value when examples exist', async ({ page }) => {
await loadApp(page);
await page.evaluate(([low, high]) => {
window.__nisps.iml.addExample(low.input, low.output);
window.__nisps.iml.addExample(high.input, high.output);
}, [EXAMPLE_LOW, EXAMPLE_HIGH]);
const loss = await page.evaluate(() => window.__nisps.iml.evalLoss());
expect(typeof loss).toBe('number');
expect(isFinite(loss)).toBe(true);
expect(loss).toBeGreaterThanOrEqual(0);
});
test('does not change weights', async ({ page }) => {
await loadApp(page);
await page.evaluate(([low, high]) => {
window.__nisps.iml.addExample(low.input, low.output);
window.__nisps.iml.addExample(high.input, high.output);
}, [EXAMPLE_LOW, EXAMPLE_HIGH]);
const { before, after } = await page.evaluate(() => {
const weightsBefore = window.__nisps.getWeights();
window.__nisps.iml.evalLoss();
const weightsAfter = window.__nisps.getWeights();
return { before: Array.from(weightsBefore), after: Array.from(weightsAfter) };
});
expect(before).toEqual(after);
});
});
test.describe('getLayerStats', () => {
test('returns 4 layers for [3, 32, 48, 64, 126] architecture', async ({ page }) => {
await loadApp(page);
const stats = await page.evaluate(() => window.__nisps.iml.getLayerStats());
expect(stats).toHaveLength(4);
});
test('each layer has all 4 stat fields with valid ranges', async ({ page }) => {
await loadApp(page);
const stats = await page.evaluate(() => window.__nisps.iml.getLayerStats());
for (const layer of stats) {
expect(typeof layer.meanAbs).toBe('number');
expect(typeof layer.maxAbs).toBe('number');
expect(typeof layer.deadFrac).toBe('number');
expect(typeof layer.satFrac).toBe('number');
expect(layer.meanAbs).toBeGreaterThanOrEqual(0);
expect(layer.maxAbs).toBeGreaterThanOrEqual(0);
expect(layer.deadFrac).toBeGreaterThanOrEqual(0);
expect(layer.deadFrac).toBeLessThanOrEqual(1);
expect(layer.satFrac).toBeGreaterThanOrEqual(0);
expect(layer.satFrac).toBeLessThanOrEqual(1);
}
});
});
test.describe('lossHistory', () => {
test('training populates lossHistory with multiple entries', async ({ page }) => {
await loadApp(page);
await page.evaluate(([low, high]) => {
window.__nisps.iml.addExample(low.input, low.output);
window.__nisps.iml.addExample(high.input, high.output);
}, [EXAMPLE_LOW, EXAMPLE_HIGH]);
const histLen = await page.evaluate(() => {
window.__nisps.train();
return window.__nisps.iml.lossHistory.length;
});
expect(histLen).toBeGreaterThan(1);
});
test('all loss history entries are finite non-negative', async ({ page }) => {
await loadApp(page);
await page.evaluate(([low, high]) => {
window.__nisps.iml.addExample(low.input, low.output);
window.__nisps.iml.addExample(high.input, high.output);
}, [EXAMPLE_LOW, EXAMPLE_HIGH]);
const history = await page.evaluate(() => {
window.__nisps.train();
return [...window.__nisps.iml.lossHistory];
});
expect(history.length).toBeGreaterThan(0);
for (const v of history) {
expect(typeof v).toBe('number');
expect(isFinite(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(0);
}
});
});
test.describe('moveWeights with pin mask', () => {
test('pinned output-layer weights unchanged, unpinned weights changed', async ({ page }) => {
await loadApp(page);
// The pin mask protects output-layer weights for pinned nodes.
// Hidden layer weights still change (shared), so we compare flat weight
// vectors and check that the output-layer segment for pinned nodes is
// identical while unpinned weights differ.
const { pinnedWeightsMatch, anyUnpinnedWeightChanged } = await page.evaluate(() => {
const iml = window.__nisps.iml;
const weightsBefore = Array.from(window.__nisps.getWeights());
// Pin first 10 outputs
const pinMask = new Uint8Array(126);
for (let i = 0; i < 10; i++) pinMask[i] = 1;
// Apply noise (spread=0 means no decay, just additive noise)
iml.moveWeights(0.3, 0, pinMask);
const weightsAfter = Array.from(window.__nisps.getWeights());
// The architecture is [3, 32, 48, 64, 126].
// Output layer: 126 nodes, each with 64+1=65 weights (64 inputs + bias).
// The output layer weights are at the end of the flat array.
const outputLayerWeights = 126 * 65; // 8190
const outputLayerStart = weightsBefore.length - outputLayerWeights;
const weightsPerNode = 65;
// Check pinned nodes (first 10) have identical weights
let pinnedAllMatch = true;
for (let n = 0; n < 10; n++) {
const nodeStart = outputLayerStart + n * weightsPerNode;
for (let w = 0; w < weightsPerNode; w++) {
if (weightsBefore[nodeStart + w] !== weightsAfter[nodeStart + w]) {
pinnedAllMatch = false;
break;
}
}
if (!pinnedAllMatch) break;
}
// Check unpinned output nodes (10-125) have at least some changed weights
let unpinnedChanged = false;
for (let n = 10; n < 126; n++) {
const nodeStart = outputLayerStart + n * weightsPerNode;
for (let w = 0; w < weightsPerNode; w++) {
if (weightsBefore[nodeStart + w] !== weightsAfter[nodeStart + w]) {
unpinnedChanged = true;
break;
}
}
if (unpinnedChanged) break;
}
return { pinnedWeightsMatch: pinnedAllMatch, anyUnpinnedWeightChanged: unpinnedChanged };
});
expect(pinnedWeightsMatch).toBe(true);
expect(anyUnpinnedWeightChanged).toBe(true);
});
});