velxio/frontend/src/__tests__/solver-determinism.test.ts

127 lines
4.5 KiB
TypeScript

/**
* Solver determinism tests (Phase 1d-tests F).
*
* For a curated set of representative examples, run `solveInput` three
* times in a row and assert that every `nodeVoltage` is bit-identical
* across the runs (within 1e-12 numerical tolerance).
*
* Why this matters: ngspice should be deterministic given the same
* netlist + same convergence options. If a future change adds a
* stateful side-effect (random init, residual state from a prior
* solve, time-of-day in the netlist), this test catches it. Without
* determinism, the snapshot tests would flake.
*
* Imports the same examples + helpers as the gallery smoke — adding
* another canonical example to the list below extends coverage.
*/
import { describe, it, expect } from 'vitest';
import { analogExamples } from '../data/examples-analog';
import { digitalExamples } from '../data/examples-digital';
import { exampleToBuildNetlistInput } from '../utils/exampleToBuildNetlistInput';
import { solveInput } from './helpers/solveInput';
import type { ExampleProject } from '../data/examples';
/**
* Canonical examples — one per archetype the solver should never
* regress on. Adding to this list is cheap; removing requires a
* conscious decision.
*/
const CANONICAL_IDS = [
// From analog gallery
'an-voltage-divider',
'an-rc-low-pass',
'an-half-wave-rectifier',
'an-bjt-switch',
'an-opamp-follower',
// From digital gallery — picks one of each gate family
'digital-and-two-switches',
'digital-or-any-switch',
'digital-not-inverter',
];
function findExample(id: string): ExampleProject | undefined {
return [...analogExamples, ...digitalExamples].find((ex) => ex.id === id);
}
function deepEqualWithTolerance(
a: Record<string, number>,
b: Record<string, number>,
tol = 1e-12,
): { ok: boolean; mismatch?: { key: string; a: number; b: number } } {
const keysA = Object.keys(a).sort();
const keysB = Object.keys(b).sort();
if (keysA.length !== keysB.length) {
return {
ok: false,
mismatch: { key: '<key set differs>', a: keysA.length, b: keysB.length },
};
}
for (let i = 0; i < keysA.length; i++) {
if (keysA[i] !== keysB[i]) {
return {
ok: false,
mismatch: { key: keysA[i]!, a: a[keysA[i]!]!, b: b[keysB[i]!]!},
};
}
}
for (const key of keysA) {
const va = a[key]!;
const vb = b[key]!;
if (Math.abs(va - vb) > tol) {
return { ok: false, mismatch: { key, a: va, b: vb } };
}
}
return { ok: true };
}
describe('Solver determinism — same netlist → same vectors across runs', () => {
for (const id of CANONICAL_IDS) {
const example = findExample(id);
if (!example) {
it.skip(`${id} (not found in canonical examples — list out of sync)`, () => {});
continue;
}
it(`${id} converges to the same nodeVoltages across 3 consecutive solves`, { timeout: 30_000 }, async () => {
const input = exampleToBuildNetlistInput(example);
const run1 = await solveInput(input);
const run2 = await solveInput(input);
const run3 = await solveInput(input);
const cmp12 = deepEqualWithTolerance(run1.nodeVoltages, run2.nodeVoltages);
const cmp23 = deepEqualWithTolerance(run2.nodeVoltages, run3.nodeVoltages);
if (!cmp12.ok) {
throw new Error(
`[${id}] run1 != run2 at "${cmp12.mismatch?.key}": ${cmp12.mismatch?.a} vs ${cmp12.mismatch?.b}`,
);
}
if (!cmp23.ok) {
throw new Error(
`[${id}] run2 != run3 at "${cmp23.mismatch?.key}": ${cmp23.mismatch?.a} vs ${cmp23.mismatch?.b}`,
);
}
// Pin the analysisMode + converged flag too — the underlying
// analysis pick shouldn't flap.
expect(run1.analysisMode).toBe(run3.analysisMode);
expect(run1.converged).toBe(run3.converged);
});
}
it('solveInput state does not leak between unrelated examples', { timeout: 30_000 }, async () => {
// Solve example A, then B, then A again — assert A's two solves
// produced the same result despite B in between. Catches
// singleton-state bugs in the NgSpiceNodeAdapter.
const a = findExample('an-voltage-divider')!;
const b = findExample('an-bjt-switch')!;
const a1 = await solveInput(exampleToBuildNetlistInput(a));
await solveInput(exampleToBuildNetlistInput(b));
const a2 = await solveInput(exampleToBuildNetlistInput(a));
const cmp = deepEqualWithTolerance(a1.nodeVoltages, a2.nodeVoltages);
if (!cmp.ok) {
throw new Error(
`state leaked: ${cmp.mismatch?.key} = ${cmp.mismatch?.a}${cmp.mismatch?.b}`,
);
}
});
});