/** * 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, b: Record, 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: '', 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}`, ); } }); });