#!/usr/bin/env node /** * HFX batch runner: executes a plan of experiments in parallel worker threads, writes per-run outputs and * appends one JSON line per run to experiment_log.jsonl (machine-readable log of EVERY simulated design). * Usage: node hfx_batch.js [--workers N] [--force] * A plan module exports { batch, stage, base (shared params), designs: [{designId, params, seeds:[..], reason, hypothesis, movie?, snapshotFractions?}] } */ const fs = require('fs'), path = require('path'), os = require('os'), crypto = require('crypto'); const { Worker, isMainThread, parentPort } = require('worker_threads'); const CORE = path.join(__dirname, '..', 'app', 'src', 'hfx_core.js'); const RUNS = path.join(__dirname, 'runs'); const LOG = path.join(__dirname, 'experiment_log.jsonl'); function runOne(job) { const HFX = require(CORE); const t0 = Date.now(); const params = Object.assign(HFX.defaultParams(), job.base || {}, job.params || {}, { seed: job.seed, recordStates: !!job.movie, maxStates: job.movie ? 2000 : 0, recordMemberHistory: true, memberHistoryEvery: 5 }); let out = { runId: job.runId, batch: job.batch, stage: job.stage, designId: job.designId, seed: job.seed, reason: job.reason || '', hypothesis: job.hypothesis || '', params: params, started: new Date(t0).toISOString() }; try { const structure = HFX.generateStructure(params); const sim = new HFX.Simulation(structure, params); sim.run(); const metrics = HFX.computeMetrics(sim); const hash = HFX.hashResults(sim); const dir = path.join(RUNS, job.runId); fs.mkdirSync(dir, { recursive: true }); const bundle = HFX.resultsBundle(sim, { runId: job.runId, batch: job.batch, stage: job.stage, designId: job.designId, reason: job.reason, hypothesis: job.hypothesis, hash: hash }); fs.writeFileSync(path.join(dir, 'results.json'), JSON.stringify(bundle)); fs.writeFileSync(path.join(dir, 'steps.csv'), HFX.stepsCSV(sim)); fs.writeFileSync(path.join(dir, 'events.csv'), HFX.eventsCSV(sim)); fs.writeFileSync(path.join(dir, 'members.csv'), HFX.membersCSV(sim)); fs.writeFileSync(path.join(dir, 'member_history.csv'), HFX.memberHistoryCSV(sim)); fs.writeFileSync(path.join(dir, 'geometry.json'), JSON.stringify(HFX.geometryJSON(structure))); // snapshots for figures: deformed state at selected displacements, reconstructed exactly from the failure history const snaps = {}; const m = sim.model; const fr = job.snapshotFractions || [0.25, 0.5, 0.75, 1.0]; const dEnd = sim.steps[sim.steps.length - 1].d; const dFinal = sim.finalFailureD >= 0 ? sim.finalFailureD : dEnd; const targets = { peak: sim.dPeak, final: dFinal, end: dEnd, first: Math.max(0, sim.firstFailureD) }; fr.forEach((f, i) => { targets['f' + i] = f * dFinal; }); const failDisp = sim.failDisp, damageDisp = sim.damageDisp; for (const key of Object.keys(targets)) { const d = targets[key]; const st = new Int8Array(structure.elements.length); for (let i = 0; i < st.length; i++) { if (structure.elements[i].absent) st[i] = 2; else if (failDisp[i] >= 0 && failDisp[i] <= d * (1 + 1e-12)) st[i] = 2; else if (damageDisp[i] >= 0 && damageDisp[i] <= d * (1 + 1e-12)) st[i] = 1; } if (key === 'peak' || key === 'first') for (let i = 0; i < st.length; i++) { if (!structure.elements[i].absent && failDisp[i] >= 0 && Math.abs(failDisp[i] - d) <= d * 1e-9) st[i] = 0; } m.state.set(st); m.rebuild(); m.solveAt(d); snaps[key] = { d: d, F: m.F, u: Array.from(m.u, v => Number(v.toPrecision(6))), util: Array.from(m.util, v => Number(v.toPrecision(5))), N: Array.from(m.N, v => Number(v.toPrecision(5))), state: Array.from(st) }; } fs.writeFileSync(path.join(dir, 'snapshots.json'), JSON.stringify(snaps)); if (job.movie && sim.stateRecords) { const recs = sim.stateRecords.map(r => ({ dFrom: r.dFrom, dTo: r.dTo === Infinity ? dEnd : r.dTo, F1: r.F1, nFailed: r.nFailed, u1: Array.from(r.u1, v => Number(v.toPrecision(6))), util1: Array.from(r.util1, v => Number(v.toPrecision(5))), N1: Array.from(r.N1, v => Number(v.toPrecision(5))), state: Array.from(r.state) })); fs.writeFileSync(path.join(dir, 'state_records.json'), JSON.stringify({ dEnd: dEnd, records: recs })); } const files = {}; for (const f of fs.readdirSync(dir)) files[f] = { bytes: fs.statSync(path.join(dir, f)).size, sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(dir, f))).digest('hex') }; Object.assign(out, { status: 'ok', simStatus: sim.status, converged: sim.converged, hash: hash, metrics: metrics, structureStats: structure.stats, levelStats: structure.levelStats, warnings: structure.warnings, fractureMode: metrics.fractureLabel, timing: { totalMs: Date.now() - t0, simMs: sim.elapsedMs, factorMs: sim.model.factorTimeMs }, dir: path.relative(path.join(__dirname, '..'), dir), files: files }); } catch (err) { Object.assign(out, { status: 'error', error: String(err && err.stack || err), timing: { totalMs: Date.now() - t0 } }); } out.finished = new Date().toISOString(); return out; } if (!isMainThread) { parentPort.on('message', (job) => { const res = runOne(job); parentPort.postMessage(res); }); } else { const args = process.argv.slice(2); const planFile = args.find(a => !a.startsWith('--')); if (!planFile) { console.error('usage: node hfx_batch.js [--workers N]'); process.exit(1); } const nWorkers = args.includes('--workers') ? parseInt(args[args.indexOf('--workers') + 1], 10) : Math.max(1, Math.min(12, os.cpus().length - 2)); const plan = require(path.resolve(planFile)); const jobs = []; for (const d of plan.designs) for (const seed of d.seeds) { const runId = plan.batch + '_' + d.designId + '_s' + seed; jobs.push({ runId: runId, batch: plan.batch, stage: plan.stage, designId: d.designId, seed: seed, base: plan.base, params: d.params, reason: d.reason, hypothesis: d.hypothesis, movie: !!d.movie, snapshotFractions: d.snapshotFractions }); } const done = new Set(); if (fs.existsSync(LOG)) for (const line of fs.readFileSync(LOG, 'utf8').split('\n')) { if (!line.trim()) continue; try { const r = JSON.parse(line); if (r.status === 'ok') done.add(r.runId); } catch (e) { } } const todo = jobs.filter(j => !done.has(j.runId) || args.includes('--force')); if (args.includes('--force') && fs.existsSync(LOG)) { // drop superseded entries so that every runId appears once in the log const ids = new Set(todo.map(j => j.runId)); const kept = fs.readFileSync(LOG, 'utf8').split('\n').filter(l => { if (!l.trim()) return false; try { return !ids.has(JSON.parse(l).runId); } catch (e) { return false; } }); fs.writeFileSync(LOG, kept.join('\n') + (kept.length ? '\n' : '')); } console.log('plan', plan.batch, 'stage', plan.stage, ':', jobs.length, 'runs,', todo.length, 'to do, workers', nWorkers); fs.mkdirSync(RUNS, { recursive: true }); const t0 = Date.now(); let next = 0, finished = 0; const results = []; const summaryLine = (r) => r.status === 'ok' ? `${r.runId.padEnd(40)} ${r.fractureMode.padEnd(26)} K0=${r.metrics.K0.toExponential(3)} Fpk=${r.metrics.Fpeak.toExponential(3)} Wf=${r.metrics.Wf.toExponential(3)} nF=${String(r.metrics.nFailed).padStart(4)} A=${r.metrics.abruptness.toFixed(2)} LI=${(r.metrics.localizationIndex || 0).toFixed(2)} pre=${r.metrics.prePeakFailures} conv=${r.converged} ${(r.timing.totalMs / 1000).toFixed(1)}s` : `${r.runId} ERROR ${r.error.split('\n')[0]}`; const dispatch = (w) => { if (next >= todo.length) { w.terminate(); return; } const job = todo[next++]; w.postMessage(job); }; for (let i = 0; i < Math.min(nWorkers, todo.length); i++) { const w = new Worker(__filename); w.on('message', (res) => { finished++; results.push(res); fs.appendFileSync(LOG, JSON.stringify(res) + '\n'); console.log(`[${finished}/${todo.length}] ` + summaryLine(res)); dispatch(w); if (finished === todo.length) console.log('batch done in', ((Date.now() - t0) / 1000).toFixed(1), 's; errors:', results.filter(r => r.status !== 'ok').length, '; not converged:', results.filter(r => r.status === 'ok' && !r.converged).length); }); w.on('error', (e) => { console.error('worker error', e); }); dispatch(w); } if (todo.length === 0) console.log('nothing to do'); }