// The Workbench: a family of circuits searched for its cheapest members, every // proposal judged by the engine (engine::discover). // // The reader writes a generator in the editor (a program with integer inputs) // and says what each member of its family must do (the same state from // |0…0⟩, or the same circuit), at which sizes, with which gates and // connectivity, and what it costs. The engine checks every proposal at every // size, never a model's judgement, and the archive keeps the cheapest verified // generator in every cell of two measures, with who proposed it, what it came // from and what it tries. Proposers are the reader, the built-in blind search // (the baseline any idea should beat) and the model, when one is connected; // each is named on what it found. Nothing changes the editor until the reader // opens an entry. // // What is new in a kept generator is said beside it: the known construction it // is, or a known result reached by a different route, or how far it is from // everything it was compared with. A candidate that breaks only one rule of // the specification and would be verified without it is an aberration: never // kept, it is offered as a reason to revise the rule. What is worth a second // look goes to Found. import { el } from './dom.js'; import { t } from './i18n.js'; import { declared } from './generators.js'; import { noveltyLabel, unaskedText } from './novelty.js'; import { tastePanel } from './taste.js'; const SAVED = 'wai.quantum-studio.workbench'; // A cost as the Studio shows it: a sum of weighted measures, to three places. const num = (x) => String(Math.round(x * 1000) / 1000); const NS = 'http://www.w3.org/2000/svg'; const svg = (tag, attrs = {}, ...kids) => { const e = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) e.setAttribute(k, String(v)); for (const k of kids) if (k != null) e.append(typeof k === 'string' ? document.createTextNode(k) : k); return e; }; // The measures a cost or an axis may name, with the words the Studio shows. export const MEASURES = { depth: t('workbench.measure_depth'), two_qubit: t('workbench.measure_two_qubit'), t_count: t('workbench.measure_t_count'), gates: t('workbench.measure_gates'), two_qubit_depth: t('workbench.measure_two_qubit_depth'), one_qubit: t('workbench.measure_one_qubit'), three_qubit: t('workbench.measure_three_qubit'), non_clifford: t('workbench.measure_non_clifford'), qubits: t('workbench.measure_qubits'), }; // The sizes a reader writes ("n = 3, 5, 8", or "3, 5, 8" for a generator with // one input, "n = 3, 5; m = 2" for two) as the specification's inputs, or why // they cannot be read. export function parseSizes(text, names) { if (!names.length) return { inputs: {} }; const out = {}; const parts = text.split(';').map((p) => p.trim()).filter(Boolean); for (const part of parts) { const m = part.match(/^(?:([A-Za-z_]\w*)\s*=\s*)?(.+)$/); const name = m[1] ?? (names.length === 1 ? names[0] : null); if (!name) return { error: t('workbench.sizes_name_each', { list: names.map((n) => `${n} = 3, 5`).join('; ') }) }; if (!names.includes(name)) return { error: t('workbench.sizes_no_input', { name, names: names.join(', ') }) }; const values = m[2].split(/[\s,]+/).filter(Boolean).map(Number); if (!values.length || values.some((v) => !Number.isInteger(v) || v < 0)) return { error: t('workbench.sizes_whole', { name }) }; out[name] = [...new Set(values)].sort((a, b) => a - b); } const missing = names.filter((n) => !out[n]); if (missing.length) return { error: t('workbench.sizes_missing', { names: missing.join(', ') }) }; return { inputs: out }; } // The specification the form describes, for the program `reference`. export function specOf({ reference, task, sizes, gates, connectivity, weights, axes }) { const names = declared(reference); const s = parseSizes(sizes, names); if (s.error) return { error: s.error }; const cost = Object.fromEntries(Object.entries(weights).filter(([, w]) => Number(w) > 0).map(([k, w]) => [k, Number(w)])); if (!Object.keys(cost).length) return { error: t('workbench.cost_needs_weight') }; const allowed = gates.split(/[\s,]+/).map((g) => g.trim().toLowerCase()).filter(Boolean); const spec = { name: 'studio', task, reference, inputs: s.inputs, connectivity, cost, axes }; if (allowed.length) spec.gates = allowed; return { spec }; } // The archive's elites on the two axes: a ring for the reference, a dot for // each other cell's best, the cheapest marked. A click on a dot picks it. export function archiveMap(summary, axes, selected, onPick) { const W = 320, H = 190, L = 34, B = 26, T = 22, R = 12; const cells = summary.cells ?? []; const xs = cells.map((c) => c.axes[0]), ys = cells.map((c) => c.axes[1]); const xmax = Math.max(1, ...xs) * 1.15, ymax = Math.max(1, ...ys) * 1.15; const sx = (v) => L + (v / xmax) * (W - L - R), sy = (v) => H - B - (v / ymax) * (H - B - T); const ticks = (max) => { const step = Math.max(1, Math.ceil(max / 4)); const out = []; for (let v = 0; v <= max; v += step) out.push(v); return out; }; const g = svg('svg', { class: 'qs-atlas qs-wb-map', id: 'qs-wb-map', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': t('workbench.map_label', { n: cells.length, x: MEASURES[axes[0]] ?? axes[0], y: MEASURES[axes[1]] ?? axes[1] }) }); for (const v of ticks(xmax)) g.append(svg('line', { x1: sx(v), y1: T, x2: sx(v), y2: H - B, class: 'qs-gridline' }), svg('text', { x: sx(v), y: H - B + 13, class: 'qs-tick', 'text-anchor': 'middle' }, String(v))); for (const v of ticks(ymax)) g.append(svg('line', { x1: L, y1: sy(v), x2: W - R, y2: sy(v), class: 'qs-gridline' }), svg('text', { x: L - 6, y: sy(v) + 3, class: 'qs-tick', 'text-anchor': 'end' }, String(v))); g.append(svg('text', { x: W - R, y: H - 2, class: 'qs-axis', 'text-anchor': 'end' }, `${MEASURES[axes[0]] ?? axes[0]} →`), svg('text', { x: L - 6, y: 10, class: 'qs-axis', 'text-anchor': 'start' }, `↑ ${MEASURES[axes[1]] ?? axes[1]}`)); const best = summary.best?.id; const kindOf = (c) => (c.proposer === 'reference' ? 'ref' : c.id === best ? 'best' : c.proposer.startsWith('model') ? 'model' : c.proposer === 'mutate' ? 'mutate' : 'person'); // Drawn so the reference and then the best lie on top where points crowd. const rank = { mutate: 0, model: 1, person: 1, ref: 2, best: 3 }; for (const c of [...cells].sort((a, b) => rank[kindOf(a)] - rank[kindOf(b)])) { const kind = kindOf(c); const p = svg('g', { class: `qs-pt qs-wb-${kind}${c.id === selected ? ' qs-sel' : ''}`, tabindex: '0', role: 'button', 'data-id': c.id, 'aria-label': t('workbench.point_label', { id: c.id, proposer: c.proposer, cost: num(c.cost), x: c.axes[0], y: c.axes[1] }) }, svg('title', {}, t('workbench.point_title', { id: c.id, proposer: c.proposer, cost: num(c.cost) })), svg('circle', { cx: sx(c.axes[0]), cy: sy(c.axes[1]), r: 8, class: 'qs-hit' }), svg('circle', { cx: sx(c.axes[0]), cy: sy(c.axes[1]), r: kind === 'ref' ? 6 : 4.5, class: kind === 'ref' ? 'qs-ring' : 'qs-dot' })); p.addEventListener('click', () => onPick(c.id)); p.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(c.id); } }); g.append(p); } return g; } // One line for an outcome of a submission. export function outcomeText(o, who) { if (o.duplicate) return t('workbench.outcome_duplicate', { who }); const e = o.evaluation; if (!e?.verified) return t('workbench.outcome_not_verified', { who, failure: e?.failure ?? t('workbench.outcome_did_not_read') }); if (o.kept && o.improvement) return t('workbench.outcome_improvement', { who, id: o.kept, cost: num(e.cost) }); if (o.kept) return t('workbench.outcome_kept', { who, id: o.kept, cost: num(e.cost) }); return t('workbench.outcome_verified', { who, cost: num(e.cost) }); } // Who a proposer is, as the program's history records it. const byOf = (p) => (p === 'mutate' ? { kind: 'engine', name: 'the blind search' } : p === 'person' || p === 'reference' ? { kind: 'person' } : p.startsWith('model') ? { kind: 'assistant', name: p.replace(/^model:?\s*/, '') || undefined } : { kind: 'agent', name: p }); // `save(name, text)` writes a file where the reader keeps them and gives back // where it went. `apply(source, note, by)` offers a generator to the editor as // `by`'s proposal. `found` is the Found pane; `taste` the reader's taste // profiles (taste.js), learned from choices between kept generators. // `paused()` is true while the reader explores on their own: the model is not asked. // `features.on(name)` says whether a feature is on (flags.js); `onChoice` hears // which kept generator the reader opened, by its rank in cost. export function workbenchPane({ engine, draft, model, apply, save, found = null, taste = null, paused = () => false, features = { on: () => true }, onChoice = null }) { const startBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-start', type: 'button', text: t('workbench.start'), title: t('workbench.start_title') }); const task = el('select', { class: 'qs-input qs-small', id: 'qs-wb-task', 'aria-label': t('workbench.task_label') }, el('option', { value: 'prepares', text: t('workbench.task_prepares') }), el('option', { value: 'equivalent', text: t('workbench.task_equivalent') })); const sizes = el('input', { class: 'qs-input', id: 'qs-wb-sizes', value: 'n = 3, 5, 8', autocomplete: 'off', 'aria-label': t('workbench.sizes_label') }); const gates = el('input', { class: 'qs-input', id: 'qs-wb-gates', placeholder: t('workbench.gates_any'), value: 'h, cx', autocomplete: 'off', 'aria-label': t('workbench.gates_label') }); const conn = el('select', { class: 'qs-input qs-small', id: 'qs-wb-conn', 'aria-label': t('workbench.conn_label') }, el('option', { value: 'all', text: t('workbench.conn_all') }), el('option', { value: 'line', text: t('workbench.conn_line') })); const weight = (k, v) => el('input', { class: 'qs-input qs-small qs-wb-w', type: 'number', min: '0', step: '0.1', value: String(v), id: `qs-wb-w-${k}`, 'aria-label': t('workbench.weight_label', { measure: MEASURES[k] }) }); const weights = { depth: weight('depth', 1), two_qubit: weight('two_qubit', 0.1), t_count: weight('t_count', 0), gates: weight('gates', 0) }; const axis = (id, chosen) => el('select', { class: 'qs-input qs-small', id, 'aria-label': id.endsWith('x') ? t('workbench.axis_first') : t('workbench.axis_second') }, ...Object.entries(MEASURES).map(([k, w]) => el('option', { value: k, text: w, selected: k === chosen }))); const ax = axis('qs-wb-ax', 'two_qubit'), ay = axis('qs-wb-ay', 'depth'); const form = el('details', { class: 'qs-wb-spec', id: 'qs-wb-spec', open: true }, el('summary', { text: t('workbench.spec') }), el('div', { class: 'qs-wb-form' }, el('label', {}, el('span', { text: t('workbench.each_member_must') }), task), el('label', {}, el('span', { text: t('workbench.checked_at') }), sizes), el('label', {}, el('span', { text: t('workbench.using') }), gates), el('label', {}, el('span', { text: t('workbench.on') }), conn), el('div', { class: 'qs-wb-cost' }, el('span', { text: t('workbench.cost_summed') }), ...Object.entries(weights).map(([k, i]) => el('label', {}, i, el('span', { text: `× ${MEASURES[k]}` })))), el('label', {}, el('span', { text: t('workbench.map') }), ax, el('span', { text: t('workbench.against') }), ay))); const searchBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-search', type: 'button', text: t('workbench.search'), title: t('workbench.search_title') }); const askBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-ask', type: 'button', text: t('workbench.ask'), title: t('workbench.ask_title') }); const submitBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-submit', type: 'button', text: t('workbench.submit'), title: t('workbench.submit_title') }); const saveBtn = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-save', type: 'button', text: t('workbench.download'), title: t('workbench.download_title') }); const acts = el('div', { class: 'qs-wb-acts', hidden: true }, searchBtn, askBtn, submitBtn, saveBtn); const stats = el('p', { class: 'qs-note', id: 'qs-wb-stats', text: t('workbench.intro') }); const mapBox = el('div', { class: 'qs-wb-mapbox', id: 'qs-wb-mapbox' }); const entry = el('div', { class: 'qs-wb-entry', id: 'qs-wb-entry' }); const log = el('ol', { class: 'qs-wb-log', id: 'qs-wb-log', 'aria-live': 'polite' }); let spec = null, archive = null, summary = null, selected = null, openedFrom = null, busy = false; // The kept generators, each the best of its cell, read at the largest size. const largest = () => Object.fromEntries(Object.entries(spec?.inputs ?? {}).map(([k, vs]) => [k, Math.max(...vs)])); const elites = () => (archive ? Object.values(archive.cells).map((i) => archive.entries[i]).sort((a, b) => a.cost - b.cost).map((e) => ({ name: e.id, qasm: e.source, cost: e.cost })) : null); const tasteUi = taste ? tastePanel({ store: taste, candidates: elites, inputs: largest, task: () => spec?.task ?? 'equivalent', onPick: (id) => pick(id) }) : null; const node = el('section', { class: 'qs-pane qs-wb', 'aria-labelledby': 'qs-wb-h' }, el('div', { class: 'qs-head' }, el('h2', { id: 'qs-wb-h', text: t('workbench.title') }), el('span', { class: 'qs-grow' }), startBtn), ...[form, acts, stats, mapBox, entry, tasteUi?.node, log].filter(Boolean)); const say = (text, cls = '', ...more) => { log.prepend(el('li', { class: cls }, text, ...more)); while (log.children.length > 12) log.lastChild.remove(); }; const persist = () => { try { localStorage.setItem(SAVED, JSON.stringify({ spec, archive })); } catch (e) {} }; const setBusy = (b) => { busy = b; for (const x of [startBtn, searchBtn, askBtn, submitBtn]) x.disabled = b; }; const entryById = (id) => archive?.entries.find((e) => e.id === id); function render() { if (!summary) return; const st = summary.stats; const energy = st.joules != null ? t('workbench.stats_energy', { joules: st.joules.toFixed(2) }) : ''; const improvements = st.improvements === 1 ? t('workbench.improvement_one', { n: st.improvements }) : t('workbench.improvement_many', { n: st.improvements }); stats.textContent = t('workbench.stats', { evaluations: st.evaluations, verified: st.verified, kept: st.kept, improvements, energy }) + t('workbench.stats_best', { id: summary.best.id, cost: num(summary.best.cost), reference: num(summary.reference.cost), proposer: summary.best.proposer, strength: summary.best.strength }); mapBox.replaceChildren(archiveMap(summary, spec.axes, selected, pick)); acts.hidden = false; if (selected) pick(selected); } // The form, showing the specification an archive was built for. function fill(sp) { task.value = sp.task; sizes.value = Object.entries(sp.inputs ?? {}).map(([n, vs]) => `${n} = ${vs.join(', ')}`).join('; '); gates.value = (sp.gates ?? []).join(', '); conn.value = sp.connectivity ?? 'all'; for (const [k, i] of Object.entries(weights)) i.value = String(sp.cost?.[k] ?? 0); [ax.value, ay.value] = sp.axes; } function lineage(id) { const out = []; for (let e = entryById(id); e && out.length < 20; e = e.parent ? entryById(e.parent) : null) out.push(e); return out; } function pick(id) { const e = entryById(id); if (!e) return; selected = id; for (const p of mapBox.querySelectorAll('.qs-pt')) p.classList.toggle('qs-sel', p.dataset.id === id); const open = el('button', { class: 'qs-btn qs-small', id: 'qs-wb-open', type: 'button', text: t('workbench.open') }); open.addEventListener('click', () => { openedFrom = id; const ranked = elites() ?? []; if (ranked.length >= 2) onChoice?.({ kind: 'workbench', k: ranked.length, picked: Math.max(0, ranked.findIndex((x) => x.name === id)), best: 0 }); apply(e.source, `${id} from the Workbench (${e.proposer}).`, byOf(e.proposer)); }); const chain = lineage(id).map((x, i) => el('button', { class: 'qs-chip', type: 'button', text: `${i ? '← ' : ''}${x.id} ${x.proposer}`, title: x.note ?? '', onclick: () => pick(x.id) })); const novel = el('div', { class: 'qs-wb-novelty', id: 'qs-wb-novelty' }); (features.on('novelty') ? engine.discoverNovelty?.(spec, archive, id) : null)?.then((v) => { if (selected !== id) return; const nv = noveltyLabel(v, { verified: true }); const u = unaskedText(v, MEASURES); novel.replaceChildren(...[nv ? el('span', { class: `qs-badge ${nv.cls}`, text: nv.text }) : null, nv ? el('p', { class: 'qs-note', text: nv.why }) : null, u ? el('p', { class: 'qs-note', text: u }) : null].filter(Boolean)); }).catch(() => {}); entry.replaceChildren(...[ el('div', { class: 'qs-cand-head' }, el('span', { class: 'qs-cand-title', text: t('workbench.entry_title', { id: e.id, cost: num(e.cost), x_measure: MEASURES[spec.axes[0]], x_value: e.axes[0], y_measure: MEASURES[spec.axes[1]], y_value: e.axes[1] }) }), el('span', { class: 'qs-badge ok', text: t('workbench.checked_by', { strength: e.strength, sealed: e.sealed ? t('workbench.sealed') : '' }) }), el('span', { class: 'qs-grow' }), open), e.note ? el('p', { class: 'qs-wb-note', text: e.note }) : null, novel, el('div', { class: 'qs-wb-chain', 'aria-label': t('workbench.lineage') }, ...chain), el('details', {}, el('summary', { text: t('workbench.its_generator') }), el('pre', { class: 'qs-ai-code', text: e.source })), ].filter(Boolean)); } async function start() { if (busy) return; const reference = draft(); const made = specOf({ reference, task: task.value, sizes: sizes.value, gates: gates.value, connectivity: conn.value, weights: Object.fromEntries(Object.entries(weights).map(([k, i]) => [k, i.value])), axes: [ax.value, ay.value] }); if (made.error) { stats.textContent = made.error; return; } setBusy(true); stats.textContent = t('workbench.checking_reference'); try { const r = await engine.discoverStart(made.spec); ({ archive, summary } = r); spec = made.spec; selected = null; openedFrom = null; log.replaceChildren(); say(t('workbench.started', { id: summary.reference.id, cost: num(summary.reference.cost) })); form.open = false; persist(); render(); } catch (e) { stats.textContent = e.message.charAt(0).toUpperCase() + e.message.slice(1); } finally { setBusy(false); } } async function submit(source, parent, proposer, note) { const r = await engine.discoverSubmit(spec, archive, source, parent, proposer, note); ({ archive, summary } = r); return r.outcome; } // The form loosened by the rule an aberration broke, for a new archive. function loosen(broke) { if (broke.kind === 'gates' && broke.gate) gates.value = [...new Set([...gates.value.split(/[\s,]+/).filter(Boolean), broke.gate])].join(', '); else if (broke.kind === 'connectivity') conn.value = 'all'; form.open = true; stats.textContent = t('workbench.loosened', { change: broke.kind === 'gates' ? t('workbench.loosened_allows', { gate: broke.gate }) : t('workbench.loosened_any_pair') }); startBtn.focus(); } // What is new in a kept entry, said and flagged when it is worth a second look. async function novelty(id, who, by) { if (!features.on('novelty')) return; const v = await engine.discoverNovelty?.(spec, archive, id).catch(() => null); const nv = noveltyLabel(v, { verified: true }); if (!nv) return; say(t('workbench.novelty_said', { id, text: nv.text }), nv.flag ? 'find' : ''); if (nv.flag) found?.flag({ kind: nv.flag, title: `${id} (${who}): ${nv.text}`, why: nv.why, where: t('workbench.title'), source: entryById(id)?.source, by }); } // What a judged candidate adds to its outcome: an aberration's invitation to // revise a rule, and what is new in it once kept. async function after(o, who, source, by) { const ab = features.on('novelty') ? o.evaluation?.aberration : null; if (ab) { const broke = ab.broke?.kind === 'gates' ? t('workbench.aberration_uses', { gate: ab.broke.gate }) : ab.broke?.kind === 'connectivity' ? t('workbench.aberration_joins') : t('workbench.aberration_breaks'); const why = t('workbench.aberration_why', { rule: ab.rule, strength: ab.strength, cost: num(ab.cost), x_measure: MEASURES[spec.axes[0]], x_value: ab.axes[0], y_measure: MEASURES[spec.axes[1]], y_value: ab.axes[1] }); const tryBtn = ab.broke ? el('button', { class: 'qs-btn qs-small qs-wb-try', type: 'button', text: t('workbench.try_without'), onclick: () => loosen(ab.broke) }) : null; say(t('workbench.aberration_said', { who, why }), 'find', tryBtn); found?.flag({ kind: 'aberration', title: `${who}: ${broke}`, why, where: t('workbench.title'), source, by }); } if (o.kept) await novelty(o.kept, who, by); } async function searchMore() { if (busy || !spec) return; setBusy(true); const total = 100, chunk = 25; const before = summary.stats.kept; const kept = []; try { for (let done = 0; done < total; done += chunk) { stats.textContent = t('workbench.search_progress', { done, total }); // A fresh seed for every step, so a search taken up again does not replay. const r = await engine.discoverSearch(spec, archive, archive.stats.evaluations + archive.stats.duplicates + 1, chunk); ({ archive, summary } = r); kept.push(...(r.kept ?? [])); } say(t('workbench.search_done', { total, kept: summary.stats.kept - before })); persist(); // Only what is worth a second look is said of the search's finds. for (const id of features.on('novelty') ? kept : []) { const nv = noveltyLabel(await engine.discoverNovelty?.(spec, archive, id).catch(() => null), { verified: true }); if (nv?.flag) { say(t('workbench.search_find_said', { id, text: nv.text }), 'find'); found?.flag({ kind: nv.flag, title: t('workbench.search_find_title', { id, text: nv.text }), why: nv.why, where: t('workbench.title'), source: entryById(id)?.source, by: byOf('mutate') }); } } } catch (e) { say(t('workbench.search_stopped', { error: e.message }), 'bad'); } finally { setBusy(false); render(); } } async function ask() { if (busy || !spec) return; const m = model(); if (paused()) { say(t('workbench.paused'), 'bad'); return; } if (!m) { say(t('workbench.needs_model'), 'bad'); return; } setBusy(true); stats.textContent = t('workbench.model_proposing'); try { const shown = Object.values(archive.cells).map((i) => archive.entries[i]).sort((a, b) => a.cost - b.cost).slice(0, 3); const ideas = await m.propose(spec, shown, 3); if (!ideas.length) say(t('workbench.model_wrote_none'), 'bad'); for (const p of ideas) { const proposer = `model: ${m.model?.name || 'the model'}`; const o = await submit(p.qasm, shown[0]?.id ?? null, proposer, p.idea || null); say(outcomeText(o, p.idea || t('workbench.an_idea')), o.kept ? 'ok' : o.evaluation?.verified ? '' : 'bad'); await after(o, p.idea || t('workbench.an_idea'), p.qasm, byOf(proposer)); } persist(); } catch (e) { say(t('workbench.model_failed', { error: e.message }), 'bad'); } finally { setBusy(false); render(); } } async function submitDraft() { if (busy || !spec) return; setBusy(true); try { const src = draft(); const parent = openedFrom && entryById(openedFrom)?.source !== src ? openedFrom : null; const o = await submit(src, parent, 'person', null); say(outcomeText(o, t('workbench.your_program')), o.kept ? 'ok' : o.evaluation?.verified ? '' : 'bad'); await after(o, t('workbench.your_program'), src, byOf('person')); persist(); } catch (e) { say(t('workbench.not_judged', { error: e.message }), 'bad'); } finally { setBusy(false); render(); } } // The specification and its archive, as the command line keeps them: // `wai-quantum discover verify NAME.json` reads NAME.archive.json beside it. async function download() { if (!archive) return; const stem = `workbench-${archive.spec.slice(0, 8)}-${archive.stats.evaluations}`; try { const where = await save(`${stem}.json`, JSON.stringify(spec, null, 2) + '\n'); await save(`${stem}.archive.json`, JSON.stringify(archive, null, 2) + '\n'); say(t('workbench.saved', { path: where, stem }), 'ok'); } catch (e) { say(t('workbench.not_saved', { error: e.message }), 'bad'); } } // The last archive comes back with the page, if the engine still reads it // for its specification (a search of no evaluations changes nothing). try { const was = JSON.parse(localStorage.getItem(SAVED) || 'null'); if (was?.spec && was?.archive) { engine.discoverSearch(was.spec, was.archive, 1, 0).then((r) => { if (spec) return; spec = was.spec; ({ archive, summary } = r); fill(spec); form.open = false; say(t('workbench.resumed', { n: summary.stats.evaluations })); render(); }).catch(() => {}); } } catch (e) {} startBtn.addEventListener('click', start); searchBtn.addEventListener('click', searchMore); askBtn.addEventListener('click', ask); submitBtn.addEventListener('click', submitDraft); saveBtn.addEventListener('click', download); return { node, start, searchMore, ask, submitDraft }; }