1// The fastandaccurate command line: run work-precision sweeps outside the
2// browser, through the same harness the site uses, and write result JSON
3// files ready to submit to the fastandaccurate-results repository by PR.
4//
5// fastandaccurate list
6// fastandaccurate run [--instance <id>] [--solver <id>]
7// [--solver-file f.m --solver-id name]
8// [--repeats N] [--max-n N] [--label "text"]
9// [--out dir]
10//
11// In development: npx tsx src/cli/main.ts run ...
13import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
14import { dirname, join, resolve } from "path";
15import { fileURLToPath } from "url";
16import os from "os";
17import { INSTANCES, getInstance } from "../problems/laplace2d/spec";
18import { setNumblFileIO } from "../harness/numblRun";
19import { NodeFileIOAdapter } from "./nodeFileIO";
21setNumblFileIO((vfs) => new NodeFileIOAdapter(vfs));
22import { SOLVERS, getSolver, type SolverManifest } from "../solvers";
23import { runSweep } from "../harness/sweep";
24import type { MatlabSources } from "../harness/runner";
25import {
26 buildResultFile,
27 toResultPoint,
28 type ResultEnvironment,
29} from "../harness/resultSchema";
31const moduleDir = dirname(fileURLToPath(import.meta.url));
33/** Locate the directory holding the .m sources: the repo's src/ in
34 * development, the bundle's own src/ in the packed CLI. */
35function findSrcRoot(): string {
36 const candidates = [join(moduleDir, "..", ".."), join(moduleDir)];
37 for (const c of candidates) {
38 if (existsSync(join(c, "src", "problems", "laplace2d", "matlab", "build_problem.m"))) {
39 return join(c, "src");
40 }
41 }
42 throw new Error("cannot locate MATLAB sources next to the CLI");
43}
45const srcRoot = findSrcRoot();
46const readSrc = (p: string) => readFileSync(join(srcRoot, p), "utf-8");
48function numblVersion(): string {
49 // The packed CLI has numbl bundled in; the version is stamped at build
50 // time. In development (tsx), read it from node_modules instead.
51 if (typeof __NUMBL_VERSION__ !== "undefined") return __NUMBL_VERSION__;
52 const c = join(srcRoot, "..", "node_modules", "numbl", "package.json");
53 if (existsSync(c)) {
54 return (JSON.parse(readFileSync(c, "utf-8")) as { version: string }).version;
55 }
56 return "unknown";
57}
59function environment(machineLabel: string | undefined, builtin: boolean): ResultEnvironment {
60 return {
61 kind: "node",
62 runtime: `node ${process.version}`,
63 numblVersion: numblVersion(),
64 os: `${os.platform()} ${os.release()}`,
65 cpu: os.cpus()[0]?.model?.trim() ?? "unknown",
66 machineLabel,
67 browserReproducible: builtin,
68 };
69}
71interface Args {
72 command: string;
73 flags: Record<string, string>;
74}
76function parseArgs(argv: string[]): Args {
77 const [command = "help", ...rest] = argv;
78 const flags: Record<string, string> = {};
79 for (let i = 0; i < rest.length; i++) {
80 const a = rest[i];
81 if (!a.startsWith("--")) throw new Error(`unexpected argument: ${a}`);
82 const key = a.slice(2);
83 const val = rest[i + 1];
84 if (val === undefined || val.startsWith("--")) {
85 flags[key] = "true";
86 } else {
87 flags[key] = val;
88 i++;
89 }
90 }
91 return { command, flags };
92}
94function listCommand() {
95 console.log("Problem: laplace-dirichlet-2d (v1)\n");
96 console.log("Instances:");
97 for (const inst of INSTANCES) {
98 console.log(` ${inst.id.padEnd(14)} ${inst.label}`);
99 }
100 console.log("\nSolvers:");
101 for (const s of SOLVERS) {
102 console.log(` ${s.id.padEnd(14)} ${s.name} (v${s.version}, ${s.backend})`);
103 }
104}
106async function runCommand(flags: Record<string, string>) {
107 const repeats = flags.repeats ? parseInt(flags.repeats, 10) : 3;
108 const maxN = flags["max-n"] ? parseInt(flags["max-n"], 10) : undefined;
109 const outDir = resolve(flags.out ?? "fastandaccurate-results-out");
110 const instances = flags.instance
111 ? [getInstance(flags.instance)]
112 : INSTANCES;
114 const base = {
115 buildProblem: readSrc("problems/laplace2d/matlab/build_problem.m"),
116 bdata: readSrc("problems/laplace2d/matlab/laplace2d_bdata.m"),
117 };
119 let solverList: { manifest: SolverManifest; sources: MatlabSources; source: string }[];
120 if (flags["solver-file"]) {
121 const file = resolve(flags["solver-file"]);
122 const id = flags["solver-id"];
123 if (!id) throw new Error("--solver-file requires --solver-id");
124 const manifest: SolverManifest = {
125 id,
126 name: id,
127 description: `custom solver from ${file}`,
128 version: flags["solver-version"] ?? "0.0.0",
129 backend: "cpu",
130 sweepN: getSolver("nystrom-dlp").sweepN,
131 };
132 solverList = [
133 {
134 manifest,
135 sources: { ...base, solver: readFileSync(file, "utf-8") },
136 source: file,
137 },
138 ];
139 } else {
140 const wanted = flags.solver ? [getSolver(flags.solver)] : SOLVERS;
141 solverList = wanted.map((manifest) => ({
142 manifest,
143 sources: { ...base, solver: readSrc(`solvers/${manifest.id}/solver.m`) },
144 source: "builtin",
145 }));
146 }
148 mkdirSync(outDir, { recursive: true });
149 const env = environment(flags.label, !flags["solver-file"]);
151 for (const inst of instances) {
152 for (const { manifest, sources, source } of solverList) {
153 console.log(`\n${inst.id} / ${manifest.id}`);
154 console.log(" n relMax relL2 solve(s)");
155 const points = runSweep({
156 instance: inst,
157 solver: manifest,
158 sources,
159 repeats,
160 maxN,
161 onPoint: (p) => {
162 console.log(
163 ` ${String(p.n).padStart(4)} ${p.relMax.toExponential(2)} ` +
164 `${p.relL2.toExponential(2)} ${p.solveSeconds.toFixed(4)}`
165 );
166 },
167 });
168 const result = await buildResultFile({
169 instance: inst,
170 solver: {
171 id: manifest.id,
172 version: manifest.version,
173 backend: manifest.backend,
174 source,
175 },
176 environment: env,
177 repeats,
178 points: points.map(toResultPoint),
179 });
180 const name = `laplace-dirichlet-2d.${inst.id}.${manifest.id}.json`;
181 const path = join(outDir, name);
182 writeFileSync(path, JSON.stringify(result, null, 2) + "\n");
183 console.log(` wrote ${path}`);
184 }
185 }
186 console.log(
187 "\nTo publish: open a pull request adding these files under results/ in " +
188 "https://github.com/concept-collection/fastandaccurate-results"
189 );
190}
192async function main() {
193 const args = parseArgs(process.argv.slice(2));
194 if (args.command === "list") {
195 listCommand();
196 } else if (args.command === "run") {
197 await runCommand(args.flags);
198 } else {
199 console.log(
200 [
201 "fastandaccurate - PDE solver benchmarks (https://concept-collection.github.io/fastandaccurate/)",
202 "",
203 "Commands:",
204 " list List problems, instances, and solvers",
205 " run Run work-precision sweeps and write result JSON files",
206 "",
207 "Run flags:",
208 " --instance <id> One instance (default: all)",
209 " --solver <id> One built-in solver (default: all)",
210 " --solver-file <f.m> A custom solver file (requires --solver-id)",
211 " --solver-id <name> Identifier for the custom solver",
212 " --solver-version <v> Version string for the custom solver",
213 " --repeats <N> Timed repeats per point (default 3)",
214 " --max-n <N> Restrict the sweep to n <= N",
215 " --label <text> Free-text machine label recorded in results",
216 " --out <dir> Output directory (default fastandaccurate-results-out)",
217 ].join("\n")
218 );
219 }
220}
222main().catch((err) => {
223 console.error(err instanceof Error ? err.message : err);
224 process.exit(1);
225});