diff --git a/package.json b/package.json index 34a57f77..ad31242e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", + "test:examples": "vitest run --config vitest.examples.config.mts", "e2e": "playwright test", "e2e:headed": "playwright test --headed", "e2e:smoke": "playwright test -c playwright.smoke.config.ts", diff --git a/public/examples/lammps/deposit_atom/in.deposit.atom b/public/examples/lammps/deposit_atom/in.deposit.atom index c040a7be..1d10352f 100644 --- a/public/examples/lammps/deposit_atom/in.deposit.atom +++ b/public/examples/lammps/deposit_atom/in.deposit.atom @@ -25,7 +25,7 @@ region mobile block 0 5 0 5 2 INF group mobile region mobile compute add addatoms temp -compute_modify add dynamic yes extra 0 +compute_modify add dynamic/dof yes extra/dof 0 fix 1 addatoms nve fix 2 mobile langevin 1.0 1.0 0.1 587283 diff --git a/public/examples/lammps/deposit_molecule/in.deposit.molecule b/public/examples/lammps/deposit_molecule/in.deposit.molecule index 9e74fada..0b3fbb60 100644 --- a/public/examples/lammps/deposit_molecule/in.deposit.molecule +++ b/public/examples/lammps/deposit_molecule/in.deposit.molecule @@ -28,7 +28,7 @@ region mobile block 0 5 0 5 2 INF group mobile region mobile compute add addatoms temp -compute_modify add dynamic yes extra 0 +compute_modify add dynamic/dof yes extra/dof 0 fix 1 addatoms nve fix 2 mobile langevin 0.1 0.1 0.1 587283 diff --git a/public/examples/lammps/pour_2d/in.pour.2d b/public/examples/lammps/pour_2d/in.pour.2d index 1e959a41..dce356a6 100644 --- a/public/examples/lammps/pour_2d/in.pour.2d +++ b/public/examples/lammps/pour_2d/in.pour.2d @@ -42,7 +42,7 @@ compute 1 all erotate/sphere thermo_style custom step atoms ke c_1 vol thermo 1000 thermo_modify lost ignore norm no -compute_modify thermo_temp dynamic yes +compute_modify thermo_temp dynamic/dof yes #dump id all atom 250 dump.pour diff --git a/public/examples/lammps/pour_2d_molecule/in.pour.2d.molecule b/public/examples/lammps/pour_2d_molecule/in.pour.2d.molecule index 970ed8fb..db8b12f5 100644 --- a/public/examples/lammps/pour_2d_molecule/in.pour.2d.molecule +++ b/public/examples/lammps/pour_2d_molecule/in.pour.2d.molecule @@ -49,7 +49,7 @@ compute 1 all erotate/sphere compute Tsphere all temp/sphere thermo_style custom step atoms ke c_1 vol thermo_modify lost ignore norm no temp Tsphere -compute_modify Tsphere dynamic yes +compute_modify Tsphere dynamic/dof yes thermo 1000 diff --git a/public/examples/lammps/pour_3d/in.pour b/public/examples/lammps/pour_3d/in.pour index aef0c079..f50e29d5 100644 --- a/public/examples/lammps/pour_3d/in.pour +++ b/public/examples/lammps/pour_3d/in.pour @@ -35,7 +35,7 @@ compute 1 all erotate/sphere thermo_style custom step atoms ke c_1 vol thermo 1000 thermo_modify lost ignore norm no -compute_modify thermo_temp dynamic yes +compute_modify thermo_temp dynamic/dof yes #dump id all atom 1000 dump.pour diff --git a/public/examples/moltemplate/benzene/run.in.npt b/public/examples/moltemplate/benzene/run.in.npt index 86615bac..6bc5fac1 100644 --- a/public/examples/moltemplate/benzene/run.in.npt +++ b/public/examples/moltemplate/benzene/run.in.npt @@ -50,7 +50,7 @@ unfix fxnve print "---------------------------------------------------------------------------" print "Optional: use short high pressure run to get rid of small bubbles." -print " (In case there are any. I'm not certain there are." +print " (In case there are any. I am not certain there are." print " Later we will restore ordinary pressure.)" print "---------------------------------------------------------------------------" fix fxlan all langevin 298.0 298.0 5000 123456 # temp: 298 K diff --git a/src/examples.smoke.ts b/src/examples.smoke.ts new file mode 100644 index 00000000..66f9ac44 --- /dev/null +++ b/src/examples.smoke.ts @@ -0,0 +1,334 @@ +// @vitest-environment node +/** + * Example-library smoke test: runs every simulation in + * public/examples/examples.json against the real atomify wasm build + * (lammps.js/wasm-atomify), the way the app does — same LammpsAdapter, same + * startup args, same script preprocessing (KOKKOS serial-style wrapping + + * group inspection), same #/bond directives, same per-step snapshot calls — + * and fails on any LAMMPS error or snapshot exception. + * + * Slow (minutes) and needs the real ~50 MB module, so it is not part of + * `npm test`. Run it with `npm run test:examples` (vitest.examples.config.mts), + * e.g. after bumping lammps.js. Knobs (env vars): + * EXAMPLES_MAX_SECONDS wall-clock budget per example before it is stopped + * like the Stop button does (default 120); errors + * after that point go unnoticed + * EXAMPLES_MAX_RUN cap literal `run N` lengths so every command in long + * scripts is reached within the budget (default 1000; + * 0 disables). Keep it generous: scripts that ramp a + * parameter over a run (fix adapt + ramp()) blow up + * when the run is cut too short (micelle at 200). + * EXAMPLES_FILTER only run examples whose id contains this string + * EXAMPLES_DEBUG echo LAMMPS output live and keep all of it in errors + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import createModule from "lammps.js/wasm-atomify"; +import type { LAMMPSWeb, LammpsModule } from "lammps.js"; + +import { LammpsAdapter } from "./wasm/lammpsAdapter"; +import { + prepareScriptForSerialStyles, + scriptOptsIntoKokkos, +} from "./utils/kokkos"; +import { + GROUP_INFO_FILE, + GROUP_VARIABLE_FILE, + parseLammpsGroups, + prepareGroupInspection, + splitGroupReady, +} from "./utils/lammpsGroups"; +import { parseBond } from "./utils/parsers"; + +interface ExampleFile { + fileName: string; + url: string; +} +interface Example { + id: string; + title: string; + inputScript: string; + files: ExampleFile[]; +} + +const EXAMPLES_DIR = join(process.cwd(), "public", "examples"); +const MAX_SECONDS = Number(process.env.EXAMPLES_MAX_SECONDS ?? 120); +const MAX_RUN = Number(process.env.EXAMPLES_MAX_RUN ?? 1000); +const FILTER = process.env.EXAMPLES_FILTER ?? ""; +// Beyond the budget the Stop path needs a synced step to act on; a script +// stuck in one long command never reaches one, so give up on it eventually. +const HARD_TIMEOUT_MS = (MAX_SECONDS + 90) * 1000; + +const examples: Example[] = ( + JSON.parse(readFileSync(join(EXAMPLES_DIR, "examples.json"), "utf8")) as { + examples: Example[]; + } +).examples.filter((example) => example.id.includes(FILTER)); + +function capRunLengths(content: string): string { + if (!MAX_RUN) return content; + return content.replace( + /^([ \t]*run[ \t]+)(\d+)\b/gm, + (line, prefix: string, steps: string) => + Number(steps) > MAX_RUN ? `${prefix}${MAX_RUN}` : line, + ); +} + +/** Same transforms as the store's syncFilesWasm. */ +function prepareFile( + content: string, + isMainScript: boolean, + kokkosOptIn: boolean, +): string { + const styled = kokkosOptIn + ? content + : prepareScriptForSerialStyles(content, { isMainScript }); + return prepareGroupInspection(capRunLengths(styled)); +} + +interface Engine { + module: LammpsModule; + native: LAMMPSWeb; + adapter: LammpsAdapter; + output: string[]; + stepErrors: string[]; + hasRun: boolean; +} + +async function loadEngine(): Promise { + const output: string[] = []; + const stepErrors: string[] = []; + // Set once the module exists; the print callback reads it. + const ref: { engine?: Engine } = {}; + const printLine = (...args: unknown[]) => { + let text = args.join(" "); + if (process.env.EXAMPLES_DEBUG) console.log("LMP>", text); + const marker = splitGroupReady(text); + if (marker.ready && ref.engine) { + const info = ref.engine.module.FS.readFile(GROUP_INFO_FILE, { + encoding: "utf8", + }); + ref.engine.module.FS.writeFile( + GROUP_VARIABLE_FILE, + ref.engine.adapter.prepareGroupVariables( + parseLammpsGroups(String(info)), + ), + ); + if (!marker.before) return; + text = marker.before; + } + ref.engine?.adapter.observeOutput(text); + output.push(text); + }; + const module = (await createModule({ + print: printLine, + printErr: printLine, + })) as unknown as LammpsModule; + const native = new module.LAMMPSWeb(); + const adapter = new LammpsAdapter(module, native); + adapter.start(); + const engine: Engine = { + module, + native, + adapter, + output, + stepErrors, + hasRun: false, + }; + ref.engine = engine; + const live = engine; + // Mirrors the worker's streamStep: everything it reads per synced step. + adapter.setStepListener(() => { + try { + const particles = native.syncParticlesWrapped(); + native.syncBondsWrapped(); + native.syncSimulationBox(); + live.adapter.snapshotModifiers(null, particles.count); + native.getCurrentStep(); + native.getRunMode(); + native.getRunStepsDone(); + native.getRunStepsTotal(); + native.getMemoryUsage(); + native.getThermo("spcpu"); + native.getThermo("cpuremain"); + native.getWalls(); + } catch (error) { + if (live.stepErrors.length < 5) { + live.stepErrors.push( + error instanceof Error ? error.message : String(error), + ); + } + } + }); + return engine; +} + +function mkdirp(module: LammpsModule, path: string) { + let current = ""; + for (const part of path.split("/").filter(Boolean)) { + current += `/${part}`; + if (!module.FS.analyzePath(current).exists) module.FS.mkdir(current); + } +} + +interface ExampleResult { + error: string; + stopped: boolean; + steps: number; + seconds: number; +} + +async function runExample( + engine: Engine, + example: Example, +): Promise { + const { module, native, adapter } = engine; + const dir = `/examples/${example.id}`; + mkdirp(module, dir); + module.FS.chdir(dir); + native.clearBondDistances(); + native.setBuildNeighborlist(false); + + const files = example.files.map((file) => ({ + fileName: file.fileName, + content: readFileSync(join(EXAMPLES_DIR, file.url), "utf8"), + })); + const inputContent = + files.find((file) => file.fileName === example.inputScript)?.content ?? ""; + const kokkosOptIn = scriptOptsIntoKokkos(inputContent); + for (const file of files) { + if (!file.content) continue; + module.FS.writeFile( + `${dir}/${file.fileName}`, + prepareFile( + file.content, + file.fileName === example.inputScript, + kokkosOptIn, + ), + ); + } + + if (engine.hasRun) adapter.reset(); + engine.hasRun = true; + for (const raw of inputContent.split("\n")) { + const line = raw.trim(); + if (!line.startsWith("#/")) continue; + const bond = parseBond(line.substring(2)); + if (bond) { + native.setBondDistance(bond.atomType1, bond.atomType2, bond.distance); + native.setBuildNeighborlist(true); + } + } + + engine.output.length = 0; + engine.stepErrors.length = 0; + const started = Date.now(); + let stopped = false; + const budget = setTimeout(() => { + stopped = true; + adapter.cancel(); + }, MAX_SECONDS * 1000); + let hardTimer: ReturnType | undefined; + const hardTimeout = new Promise<"hung">((resolve) => { + hardTimer = setTimeout(() => resolve("hung"), HARD_TIMEOUT_MS); + }); + const outcome = await Promise.race([ + adapter + .runFile(`${dir}/${example.inputScript}`) + .then(() => "done" as const), + hardTimeout, + ]); + clearTimeout(budget); + clearTimeout(hardTimer); + const seconds = (Date.now() - started) / 1000; + if (outcome === "hung") { + return { + error: `did not finish within ${HARD_TIMEOUT_MS / 1000}s (no synced step to stop on)`, + stopped, + steps: 0, + seconds, + }; + } + + if (stopped) { + // A stopped run resolves runFile after a grace period while the wasm may + // still be unwinding (calls would return asyncify promises), so don't + // touch this engine again: report it and let the caller load a fresh one. + return { error: "", stopped, steps: -1, seconds }; + } + let error = adapter.getErrorMessage(); + if (error.includes("Atomify::canceled")) error = ""; + if (!error) { + // The worker's final post-run snapshot. + try { + const particles = native.syncParticlesWrapped(); + adapter.snapshotModifiers(null, particles.count); + } catch (snapshotError) { + error = `final snapshot: ${ + snapshotError instanceof Error + ? snapshotError.message + : String(snapshotError) + }`; + } + } + if (!error && engine.stepErrors.length) { + error = `step snapshot: ${engine.stepErrors[0]}`; + } + if (!error) { + const lammpsError = engine.output.find((line) => /^ERROR/.test(line)); + if (lammpsError) error = lammpsError; + } + if (error) { + const tail = engine.output + .slice(process.env.EXAMPLES_DEBUG ? 0 : -8) + .join("\n"); + error = `${error}\n--- last output ---\n${tail}`; + } + return { error, stopped, steps: native.getCurrentStep(), seconds }; +} + +describe("example library", () => { + let engine: Engine; + const failures: string[] = []; + + beforeAll(async () => { + engine = await loadEngine(); + }, 180_000); + + afterAll(() => { + if (failures.length) { + console.log(`\nFailing examples:\n${failures.join("\n")}`); + } + }); + + it.each(examples.map((example) => [example.id, example] as const))( + "%s", + async (_id, example) => { + let result: ExampleResult; + try { + result = await runExample(engine, example); + } catch (error) { + result = { + error: error instanceof Error ? error.message : String(error), + stopped: false, + steps: 0, + seconds: 0, + }; + } + console.log( + `${example.id}: ${result.error ? "FAILED" : "ok"} ` + + `(${result.seconds.toFixed(1)}s, step ${result.steps}` + + `${result.stopped ? ", stopped at budget" : ""})`, + ); + if (result.error) failures.push(example.id); + if (result.error || result.stopped) { + // A crashed, hung or stopped module poisons later runs; start fresh. + engine = await loadEngine(); + } + expect(result.error).toBe(""); + }, + HARD_TIMEOUT_MS + 120_000, + ); +}); diff --git a/src/utils/lammpsGroups.test.ts b/src/utils/lammpsGroups.test.ts index 46909a44..e262d912 100644 --- a/src/utils/lammpsGroups.test.ts +++ b/src/utils/lammpsGroups.test.ts @@ -3,6 +3,7 @@ import { GROUP_READY, parseLammpsGroups, prepareGroupInspection, + splitGroupReady, } from "./lammpsGroups"; it("reads static and dynamic names from the runtime registry", () => { @@ -23,3 +24,22 @@ it("instruments direct, continued, and conditional runs without touching comment "Atoms # atomic\n1 1 2 3 4\n", ); }); + +it("finds the ready marker alone or glued to unterminated output", () => { + expect(splitGroupReady(`${GROUP_READY}\n`)).toEqual({ + ready: true, + before: "", + }); + expect( + splitGroupReady( + `WARNING: Changed valency_val to valency_boc for X${GROUP_READY}`, + ), + ).toEqual({ + ready: true, + before: "WARNING: Changed valency_val to valency_boc for X", + }); + expect(splitGroupReady("Step Temp")).toEqual({ + ready: false, + before: "Step Temp", + }); +}); diff --git a/src/utils/lammpsGroups.ts b/src/utils/lammpsGroups.ts index 0be36a77..01f61be0 100644 --- a/src/utils/lammpsGroups.ts +++ b/src/utils/lammpsGroups.ts @@ -13,6 +13,23 @@ export function prepareGroupInspection(content: string): string { ); } +/** + * Detect the GROUP_READY marker in one line of engine output. The marker can + * arrive glued to the end of the previous output when that output lacked a + * trailing newline, so match it as a suffix and hand back any text before it. + */ +export function splitGroupReady(text: string): { + ready: boolean; + before: string; +} { + const trimmed = text.trimEnd(); + if (!trimmed.endsWith(GROUP_READY)) return { ready: false, before: text }; + return { + ready: true, + before: trimmed.slice(0, -GROUP_READY.length).trimEnd(), + }; +} + /** Parse the engine's actual group registry, including variable-expanded IDs. */ export function parseLammpsGroups(info: string): string[] { return ( diff --git a/src/wasm/lammps.worker.ts b/src/wasm/lammps.worker.ts index 97f31359..adb934d4 100644 --- a/src/wasm/lammps.worker.ts +++ b/src/wasm/lammps.worker.ts @@ -2,8 +2,8 @@ import { GROUP_INFO_FILE, GROUP_VARIABLE_FILE, - GROUP_READY, parseLammpsGroups, + splitGroupReady, } from "../utils/lammpsGroups"; /** * LAMMPS Web Worker entry. @@ -268,8 +268,9 @@ async function load() { return; } const printLine = (...args: unknown[]) => { - const text = args.join(" "); - if (text.trim() === GROUP_READY && module && adapter) { + let text = args.join(" "); + const marker = splitGroupReady(text); + if (marker.ready && module && adapter) { // Pure JS/MEMFS work inside the print callback; no nested native calls. // LAMMPS executes this generated include after print returns, before run. const info = module.FS.readFile(GROUP_INFO_FILE, { encoding: "utf8" }); @@ -277,7 +278,8 @@ async function load() { GROUP_VARIABLE_FILE, adapter.prepareGroupVariables(parseLammpsGroups(String(info))), ); - return; + if (!marker.before) return; + text = marker.before; } adapter?.observeOutput(text); post({ type: "printed", text }); diff --git a/vitest.examples.config.mts b/vitest.examples.config.mts new file mode 100644 index 00000000..efc33388 --- /dev/null +++ b/vitest.examples.config.mts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +// Runs the example-library smoke test (src/examples.smoke.ts) against the real +// atomify wasm build. Separate from vitest.config.mts: it needs a plain node +// environment (pthreads via worker_threads), no wasm stub, and minutes of time. +export default defineConfig({ + test: { + environment: "node", + include: ["src/examples.smoke.ts"], + pool: "forks", + fileParallelism: false, + testTimeout: 600_000, + hookTimeout: 300_000, + }, +});