From d7eb6a51da055f57b8bc24d8331a5d00a0c25eaf Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Mon, 26 Oct 2026 19:06:03 +0100 Subject: [PATCH] feat(gui): integrate CopomuS mutation design (#307) --- .github/workflows/build-wasm.yml | 4 + ChangeLog | 25 +++ README.md | 5 +- gui/app.js | 68 ++++++++- gui/copomus-options.js | 58 +++++++ gui/copomus-worker.js | 89 +++++++++++ gui/copomus.js | 122 +++++++++++++++ gui/docs/copomus.md | 51 +++++++ gui/docs/json-workflow.md | 12 +- gui/docs/step3-frontend-ui.md | 7 +- gui/docs/step4-results-and-deployment.md | 6 +- gui/examples/index.json | 2 +- gui/examples/mutation-design.json | 27 ++++ gui/index.html | 33 +++- gui/parameter-file.js | 22 ++- gui/protocol.js | 17 ++- gui/runner.js | 6 +- gui/sessions.js | 14 +- gui/styles.css | 71 ++++++++- gui/tests/parameter-file.test.mjs | 5 +- gui/tests/protocol.test.mjs | 17 +++ gui/uploads.js | 2 +- gui/visualization.js | 6 +- python/CopomuS.py | 45 +----- python/copomus/Makefile.am | 4 +- python/copomus/__init__.py | 2 +- python/copomus/browser.py | 187 +++++++++++++++++++++++ python/copomus/workflow.py | 53 +++++++ python/tests/test_copomus.py | 39 +++++ scripts/wasm/build.sh | 17 ++- scripts/wasm/stage.sh | 23 ++- scripts/wasm/test-browser.py | 71 +++++++-- scripts/wasm/test-vienna-release.py | 10 ++ scripts/wasm/write-build-info.mjs | 8 +- 34 files changed, 1017 insertions(+), 111 deletions(-) create mode 100644 gui/copomus-options.js create mode 100644 gui/copomus-worker.js create mode 100644 gui/copomus.js create mode 100644 gui/docs/copomus.md create mode 100644 gui/examples/mutation-design.json create mode 100644 python/copomus/browser.py create mode 100644 python/copomus/workflow.py diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index f951d568..86c2720c 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -92,7 +92,11 @@ jobs: test -s _site/gui/wasm/intarna.wasm test -s _site/gui/vendor/plotly-cartesian.min.js test -s _site/gui/vendor/plotly.css + test -s _site/gui/vendor/pyodide/pyodide.asm.wasm + test -s _site/gui/copomus-worker.js + test -s _site/gui/python/copomus/browser.py test -s _site/gui/licenses/Plotly.txt + test -s _site/gui/licenses/Pyodide.txt - uses: actions/upload-artifact@v4 with: name: intarna-pages-preview diff --git a/ChangeLog b/ChangeLog index c32a35ad..dde65fec 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,9 @@ ## Interface and handling +- integrate CopomuS as the browser Mutation Design task with single-pair input, + native controls, extended tables and four-state vaRRI visualization (#307) + - fix IntaRNApvalue both-strand sampling and parameter consistency, bound retries, validate FASTA/fits and expose empirical p-values (PR #298) @@ -134,6 +137,9 @@ ## Technical changes and Optimizations +- run the existing CopomuS Python workflow in checksum-pinned, self-hosted + Pyodide and replace subprocesses with validated JSON calls to IntaRNA WASM (#307) + - share Python companion-tool command execution, input validation and atomic output handling after rebasing onto IntaRNA-fuse (PR #298 review) @@ -328,6 +334,25 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261026 Alexander Mitrofanov + * gui/{index.html,app.js,styles.css,copomus*.js,runner.js,sessions.js}, + gui/examples/, gui/tests/, scripts/wasm/test-browser.py + + Add Mutation Design after Prediction and Evaluation, enforce one unambiguous + sequence per strand, and expose CopomuS measures, selection, filters, + generators, thresholds and explicit encodings in the Sequences panel. + + Retain ranked extended TSV output in sessions and compare wildtype, both + single mutants and the compensatory mutant in vaRRI (issue #307). + * python/copomus/{browser.py,workflow.py}, python/CopomuS.py, + python/tests/test_copomus.py + + Share CLI/browser ranking and output logic; run the existing mutation, + filter and measure classes asynchronously against JSON prediction callbacks + without changing CopomuS CLI behavior or output. + * scripts/wasm/{build.sh,stage.sh,write-build-info.mjs}, + .github/workflows/build-wasm.yml, README.md, gui/docs/ + + Download and verify self-hosted Pyodide 314.0.7, stage the required Python + sources and MPL notice, record runtime digests, and validate the complete + Pyodide-to-IntaRNA-WASM workflow in Chromium and Firefox. + 261024 Alexander Mitrofanov * python/tests/test_common_tools.py * Compare executable lookup against canonical fixture paths so macOS /var diff --git a/README.md b/README.md index 94a590f3..30ac8f69 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,16 @@ **Efficient RNA-RNA interaction prediction incorporating accessibility and seeding of interaction sites** -Predict short RNA pairs or evaluate supplied structures locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/). +Predict short RNA pairs, evaluate supplied structures or rank compensatory mutations locally with the [browser interface](https://backofenlab.github.io/IntaRNA/gui/). The browser accepts up to 50 records per strand, 2–5,000 nucleotides each, with longest selected query × longest selected target at most 1,000,000. Prediction defaults to 10 interactions per pair and includes opening and seed energies; evaluation selects all energy columns. +Mutation Design accepts one unambiguous query/target pair, runs CopomuS in a self-hosted Pyodide worker, and uses the same IntaRNA WebAssembly engine for every wildtype, single-mutant and compensatory prediction. Its extended table and vaRRI controls compare all four mutation states. Computed runs stay in separate, foldable sessions under Results in the current tab. Name and select sessions to restore their inputs and compare results; editing or resetting parameters keeps earlier runs. Select an interaction to view it in vaRRI, or download a session’s ZIP with inputs, compiled versions, reproducible parameters and all outputs. Sessions are temporary and are cleared when the page is reloaded. Minimum-energy and interaction-probability output files include local Plotly visualizations with PNG export; every additional file retains its original text view and download. See the [WebAssembly build instructions](gui/docs/step1-wasm-compilation.md) and [interface/deployment notes](gui/docs/step4-results-and-deployment.md). +The [CopomuS browser architecture](gui/docs/copomus.md) describes its controls, +JSON boundary, workload limit and reproducibility behavior. During the last few years, several new small regulatory RNAs diff --git a/gui/app.js b/gui/app.js index c95fcff2..51ddae30 100644 --- a/gui/app.js +++ b/gui/app.js @@ -6,6 +6,7 @@ import { downloadText } from './results.js'; import { SessionsView } from './sessions.js'; import { importParameters, serializeParameters, MAX_PARAMETER_BYTES } from './parameter-file.js'; import { readFastaFile } from './uploads.js'; +import { COPOMUS_DEFAULTS, COPOMUS_FILTERS, COPOMUS_MEASURES } from './copomus-options.js'; const byId = (id) => document.getElementById(id); const form = byId('prediction-form'), runner = new PredictionRunner(); @@ -24,6 +25,28 @@ function element(tag, text, className) { if (className) node.className = className; return node; } +const copomusMeasureInputs = [], copomusMeasureRows = new Map(); +for (const [key, labelText] of COPOMUS_MEASURES) { + const input = checkbox(byId('copomus-measures'), key, labelText), label = input.parentElement; + const row = element('div', '', 'measure-choice'), actions = element('span', '', 'measure-priority'); + label.replaceWith(row); row.append(label, actions); copomusMeasureRows.set(key, row); copomusMeasureInputs.push(input); + for (const [text, direction] of [['↑', -1], ['↓', 1]]) { + const button = element('button', text, 'text-button'); button.type = 'button'; + button.setAttribute('aria-label', `Move ${labelText} ${direction < 0 ? 'earlier' : 'later'} in ranking priority`); + button.addEventListener('click', () => { + const sibling = direction < 0 ? row.previousElementSibling : row.nextElementSibling; + if (!sibling) return; + if (direction < 0) row.parentElement.insertBefore(row, sibling); else row.parentElement.insertBefore(sibling, row); + inputsChanged('CopomuS measure priority changed. Compute to update the mutations.'); + }); + actions.append(button); + } +} +const copomusFilterInputs = COPOMUS_FILTERS.map(([key, label]) => checkbox(byId('copomus-filters'), key, label)); +function orderCopomusMeasures(selected) { + const order = [...selected, ...COPOMUS_MEASURES.map(([key]) => key).filter((key) => !selected.includes(key))]; + for (const key of order) byId('copomus-measures').append(copomusMeasureRows.get(key)); +} function activateGroup(key, focus = false) { for (const tab of byId('parameter-tabs').children) { const active = tab.getAttribute('aria-controls') === key; @@ -178,22 +201,25 @@ function updateLength(name) { } catch { byId(`${name}-length`).textContent = 'Check sequence format and length'; } } function updateAvailability() { - const evaluation = getTask() === 'evaluation'; + const evaluation = getTask() === 'evaluation', mutation = getTask() === 'mutation'; for (const button of byId('example-options').querySelectorAll('[data-example-task]')) { button.hidden = button.dataset.exampleTask !== getTask(); } byId('evaluation-input').hidden = !evaluation; - byId('task-help').textContent = evaluation ? 'Evaluate supplied structures for one query and one target. Prediction constraints are ignored.' : 'Predict interactions for all query–target sequence pairs.'; - byId('compute').textContent = evaluation ? 'Evaluate interactions' : 'Compute interactions'; + byId('mutation-input').hidden = !mutation; + byId('task-help').textContent = evaluation ? 'Evaluate supplied structures for one query and one target. Prediction constraints are ignored.' : mutation ? 'Rank compensatory point mutations for one query and one target with CopomuS.' : 'Predict interactions for all query–target sequence pairs.'; + byId('sequence-help').textContent = mutation ? 'One plain sequence or one FASTA record per field, A/C/G/U/T. CopomuS ranks mutations with repeated local IntaRNA predictions.' : 'Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 50 records per field and 2–5,000 nt per record; longest selected query × longest selected target ≤ 1,000,000. FASTA names stop at the first whitespace. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.'; + byId('compute').textContent = evaluation ? 'Evaluate interactions' : mutation ? 'Design mutations' : 'Compute interactions'; for (const definition of GROUPS) { - const tab = byId(`tab-${definition.key}`); tab.hidden = evaluation && !!definition.prediction; + const unavailable = evaluation && !!definition.prediction || mutation && ['output-options', 'csv-options'].includes(definition.key); + const tab = byId(`tab-${definition.key}`); tab.hidden = unavailable; if (tab.hidden) { byId(definition.key).hidden = true; if (tab.getAttribute('aria-selected') === 'true') activateGroup('sequence-input'); } for (const field of definition.fields) { const input = byId(field.key); - input.disabled = evaluation && !!field.prediction || field.key.startsWith('helix') && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; + input.disabled = unavailable || evaluation && !!field.prediction || field.key.startsWith('helix') && byId('model').value !== 'B' || field.key === 'kineticScore' && byId('mode').value !== 'K'; } } byId('noSeed').checked = byId('seed-mode').value === 'none'; @@ -209,7 +235,10 @@ function updateAvailability() { } // Upload controls follow the text area they populate, including async reads. for (const upload of form.querySelectorAll('input[data-for]')) upload.disabled = byId(upload.dataset.for).disabled; - for (const input of outputInputs) { input.disabled = evaluation && EXTRA_OUTPUTS.find((entry) => entry.key === input.value).prediction; if (input.disabled) input.checked = false; } + for (const input of outputInputs) { input.disabled = mutation || evaluation && EXTRA_OUTPUTS.find((entry) => entry.key === input.value).prediction; if (input.disabled) input.checked = false; } + const explicitMutations = !!byId('copomus-mutation-encoding').value.trim(); + for (const input of [byId('copomus-candidate-selection'), byId('copomus-generator'), ...copomusFilterInputs]) input.disabled = !mutation || explicitMutations; + for (const input of [...copomusMeasureInputs, byId('copomus-alpha'), byId('copomus-beta'), byId('copomus-mutation-encoding')]) input.disabled = !mutation; updateLength('query'); updateLength('target'); } function resetParameters({ clearInputs = false } = {}) { @@ -219,12 +248,19 @@ function resetParameters({ clearInputs = false } = {}) { for (const input of form.querySelectorAll('input[type=file]')) input.value = ''; byId('examples-menu').open = false; activateGroup('sequence-input'); } - csvOrder = defaultColumns(getTask()); + csvOrder = getTask() === 'mutation' ? CSV_COLUMNS.split(',') : defaultColumns(getTask()); byId('seed-mode').value = 'identify'; for (const field of FIELDS) { const input = byId(field.key); if (field.type === 'checkbox') input.checked = UI_DEFAULTS[field.key]; else input.value = UI_DEFAULTS[field.key]; } syncAccessibilityFlags(); for (const input of csvInputs) input.checked = csvOrder.includes(input.value); for (const input of outputInputs) input.checked = false; + for (const input of copomusMeasureInputs) input.checked = COPOMUS_DEFAULTS.measures.includes(input.value); + orderCopomusMeasures(COPOMUS_DEFAULTS.measures); + for (const input of copomusFilterInputs) input.checked = false; + byId('copomus-candidate-selection').value = COPOMUS_DEFAULTS.candidateSelection; + byId('copomus-generator').value = COPOMUS_DEFAULTS.generator; + byId('copomus-mutation-encoding').value = COPOMUS_DEFAULTS.mutationEncoding; + byId('copomus-alpha').value = COPOMUS_DEFAULTS.alpha; byId('copomus-beta').value = COPOMUS_DEFAULTS.beta; byId('qIdxPos0').value = 1; byId('tIdxPos0').value = 1; byId('rri').value = ''; updateAvailability(); errorBox.hidden = true; inputsChanged(clearInputs ? 'Inputs cleared. Enter sequences or choose an example.' : 'Parameters reset to task defaults. Compute to update the interactions.'); } @@ -259,6 +295,14 @@ function applyParameters(request, message = 'Parameters loaded. Compute to analy csvOrder = [...request.csvColumns]; for (const input of csvInputs) input.checked = request.csvColumns.includes(input.value); for (const input of outputInputs) input.checked = request.outputs.includes(input.value); + const copomus = request.copomus ?? COPOMUS_DEFAULTS; + orderCopomusMeasures(copomus.measures); + for (const input of copomusMeasureInputs) input.checked = copomus.measures.includes(input.value); + for (const input of copomusFilterInputs) input.checked = copomus.candidateFilters.includes(input.value); + byId('copomus-candidate-selection').value = copomus.candidateSelection; + byId('copomus-generator').value = copomus.generator; + byId('copomus-mutation-encoding').value = copomus.mutationEncoding; + byId('copomus-alpha').value = copomus.alpha; byId('copomus-beta').value = copomus.beta; updateAvailability(); activateGroup('sequence-input'); inputsChanged(message); } for (const strand of ['query', 'target']) { @@ -360,6 +404,14 @@ function currentRequest() { task: getTask(), query: byId('query').value, target: byId('target').value, qIdxPos0: byId('qIdxPos0').value, tIdxPos0: byId('tIdxPos0').value, rri: byId('rri').value, parameters: currentParameters(), + copomus: { + measures: [...byId('copomus-measures').querySelectorAll('input:checked')].map((input) => input.value), + candidateSelection: byId('copomus-candidate-selection').value, + candidateFilters: copomusFilterInputs.filter((input) => input.checked).map((input) => input.value), + generator: byId('copomus-generator').value, + mutationEncoding: byId('copomus-mutation-encoding').value, + alpha: byId('copomus-alpha').value, beta: byId('copomus-beta').value, + }, csvColumns: columns, outputs: outputInputs.filter((input) => input.checked && !input.disabled).map((input) => input.value), }; } @@ -394,5 +446,5 @@ const buildInfo = fetch(new URL('./build-info.txt', import.meta.url)).then((resp buildInfo.catch(() => {}); fetch(new URL('./build-info.json', import.meta.url)).then((response) => { if (!response.ok) throw new Error('Missing build provenance'); return response.json(); -}).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; }); +}).then((info) => { byId('version-info').textContent = `IntaRNA ${info.intarna} (${info.commit.slice(0, 12)}${info.dirty ? ', modified sources' : ''}) · ViennaRNA ${info.viennarna} · Emscripten ${info.emscripten}${info.pyodide ? ` · Pyodide ${info.pyodide}` : ''}`; }).catch(() => { byId('version-info').textContent = 'Build versions unavailable. Serve the staged interface with its compiled engine and build-info files.'; }); if (!globalThis.Worker || !globalThis.WebAssembly) { byId('compute').disabled = true; errorBox.textContent = 'This browser needs WebAssembly and Web Workers. Use a current browser or the command line version.'; errorBox.hidden = false; } diff --git a/gui/copomus-options.js b/gui/copomus-options.js new file mode 100644 index 00000000..17c5d362 --- /dev/null +++ b/gui/copomus-options.js @@ -0,0 +1,58 @@ +export const COPOMUS_MEASURES = Object.freeze([ + ['mfeCover', 'MFE coverage'], ['E', 'Interaction energy'], + ['minDeltaE', 'Minimum energy difference'], ['EDqi', 'Query accessibility'], + ['cEDqi', 'Constant query accessibility'], ['Eqi', 'Query energy profile'], + ['Eti', 'Target energy profile'], +]); +export const COPOMUS_FILTERS = Object.freeze([ + ['GU', 'Exclude GU/UG pairs'], ['AU', 'Exclude AU/UA pairs'], + ['GC', 'Exclude GC/CG pairs'], ['lp', 'Exclude unstackable lonely pairs'], + ['lpMfe', 'Exclude lonely pairs in the MFE'], ['he', 'Exclude potential helix ends'], + ['heMfe', 'Exclude helix ends in the MFE'], +]); +export const COPOMUS_DEFAULTS = Object.freeze({ + measures: ['mfeCover', 'E', 'minDeltaE'], + candidateSelection: 'mfe', candidateFilters: [], generator: 'flip', + mutationEncoding: '', alpha: 1, beta: 1, +}); + +const choices = (catalog) => new Set(catalog.map(([key]) => key)); +const measureChoices = choices(COPOMUS_MEASURES), filterChoices = choices(COPOMUS_FILTERS); +const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); +const sequenceOffset = (index, start) => index - start - (start < 0 && index > 0 ? 1 : 0); + +export function normalizeCopomus(options = {}, context) { + if (!isObject(options)) throw new Error('Mutation Design settings must be an object.'); + const normalized = { ...COPOMUS_DEFAULTS, ...options }; + for (const [key, catalog, label, nonempty] of [ + ['measures', measureChoices, 'measures', true], + ['candidateFilters', filterChoices, 'candidate filters', false], + ]) { + const values = normalized[key]; + if (!Array.isArray(values) || nonempty && !values.length || new Set(values).size !== values.length || values.some((value) => !catalog.has(value))) throw new Error(`Choose available CopomuS ${label}, without duplicates.`); + normalized[key] = [...values]; + } + if (!['mfe', 'mfeSO'].includes(normalized.candidateSelection)) throw new Error('Choose an available CopomuS candidate-selection method.'); + if (!['flip', 'any'].includes(normalized.generator)) throw new Error('Choose an available CopomuS mutation generator.'); + if (typeof normalized.mutationEncoding !== 'string' || normalized.mutationEncoding.length > 10000 || normalized.mutationEncoding.includes('\0')) throw new Error('Mutation encoding must be at most 10,000 text characters.'); + normalized.mutationEncoding = normalized.mutationEncoding.trim().toUpperCase().replaceAll(' ', ''); + for (const key of ['alpha', 'beta']) { + const value = typeof normalized[key] === 'number' ? normalized[key] : typeof normalized[key] === 'string' && normalized[key].trim() ? Number(normalized[key]) : NaN; + if (!Number.isFinite(value)) throw new Error(`${key}: enter a finite number.`); + normalized[key] = value; + } + if (!context) return normalized; + const { query, target, qIdxPos0, tIdxPos0 } = context; + if (query.sequence.includes('N') || target.sequence.includes('N')) throw new Error('Mutation Design requires unambiguous A, C, G or U sequences.'); + if (!normalized.mutationEncoding) return normalized; + for (const entry of normalized.mutationEncoding.split(',')) { + const match = /^([ACGU])(-?\d+)([ACGU])&([ACGU])(-?\d+)([ACGU])$/.exec(entry); + if (!match) throw new Error(`Invalid mutation encoding: “${entry}”. Use query-first substitutions such as G1C&C10G.`); + const [, queryWild, queryPosition, queryMutated, targetWild, targetPosition, targetMutated] = match; + const queryIndex = sequenceOffset(Number(queryPosition), qIdxPos0), targetIndex = sequenceOffset(Number(targetPosition), tIdxPos0); + if (Number(queryPosition) === 0 || Number(targetPosition) === 0 || queryIndex < 0 || queryIndex >= query.sequence.length || targetIndex < 0 || targetIndex >= target.sequence.length) throw new Error('A mutation position is outside its sequence or uses skipped coordinate zero.'); + if (query.sequence[queryIndex] !== queryWild || target.sequence[targetIndex] !== targetWild) throw new Error('Mutation encoding does not match the wildtype sequences.'); + if (queryWild === queryMutated || targetWild === targetMutated) throw new Error('Both encoded bases must change.'); + } + return normalized; +} diff --git a/gui/copomus-worker.js b/gui/copomus-worker.js new file mode 100644 index 00000000..29a4391f --- /dev/null +++ b/gui/copomus-worker.js @@ -0,0 +1,89 @@ +import { prepareRequest, makeParameterDocument } from './protocol.js'; + +const PYTHON_FILES = [ + 'intarna_tools.py', + 'copomus/__init__.py', 'copomus/browser.py', 'copomus/workflow.py', + 'copomus/IntaRNA.py', 'copomus/indexing.py', 'copomus/candidate_filters.py', + 'copomus/measures.py', 'copomus/mutation.py', 'copomus/mutation_generators.py', +]; +const CALL_COLUMNS = new Set(['id1', 'start1', 'end1', 'id2', 'start2', 'end2', 'subseqDP', 'hybridDP', 'E', 'bpList']); +const CALL_OUTPUTS = new Set(['qAcc', 'qMinE', 'tMinE']); +let started = false; + +self.onmessage = async ({ data }) => { + if (started) return; + started = true; + const diagnostics = []; + const log = (line) => { + diagnostics.push(String(line).slice(0, 2000)); + if (diagnostics.length > 50) diagnostics.shift(); + }; + try { + const request = prepareRequest(data); + if (request.task !== 'mutation') throw new Error('The mutation worker only accepts Mutation Design requests.'); + self.postMessage({ type: 'status', message: 'Loading Python and the prediction engine…' }); + const [{ loadPyodide }, { default: createIntaRNA }] = await Promise.all([ + import('./vendor/pyodide/pyodide.mjs'), import('./wasm/intarna.js'), + ]); + const pyodide = await loadPyodide({ + indexURL: new URL('./vendor/pyodide/', import.meta.url).href, + stdout: log, stderr: log, + }); + pyodide.FS.mkdirTree('/app/copomus'); + await Promise.all(PYTHON_FILES.map(async (name) => { + const response = await fetch(new URL(`./python/${name}`, import.meta.url)); + if (!response.ok) throw new Error(`Could not load CopomuS Python module ${name}.`); + pyodide.FS.writeFile(`/app/${name}`, await response.text(), { encoding: 'utf8' }); + })); + pyodide.runPython("import sys; sys.path.insert(0, '/app')"); + + const predict = async (payloadJson) => { + const call = JSON.parse(String(payloadJson)); + if (typeof call.query !== 'string' || typeof call.target !== 'string' + || call.qIdxPos0 !== request.parameters.qIdxPos0 || call.tIdxPos0 !== request.parameters.tIdxPos0 + || !Array.isArray(call.columns) || !call.columns.length || call.columns.some((column) => !CALL_COLUMNS.has(column)) + || !Number.isInteger(call.outNumber) || call.outNumber < 1 || call.outNumber > 1000 + || !Array.isArray(call.outputs) || call.outputs.some((output) => !CALL_OUTPUTS.has(output)) + || call.parameters === null || typeof call.parameters !== 'object' || Array.isArray(call.parameters) + || Object.keys(call.parameters).some((key) => !['qRegion', 'tRegion'].includes(key))) { + return JSON.stringify({ error: 'CopomuS sent an invalid IntaRNA request.' }); + } + const module = await createIntaRNA({ + noInitialRun: true, + locateFile: () => new URL('./wasm/intarna.wasm', import.meta.url).href, + print: log, printErr: log, + }); + const document = makeParameterDocument(request); + const parameters = document.parameters; + parameters.query = { text: `>${request.query.records[0].id}\n${call.query}\n` }; + parameters.target = { text: `>${request.target.records[0].id}\n${call.target}\n` }; + parameters.outMode = 'C'; parameters.outCsvCols = call.columns.join(','); parameters.outNumber = call.outNumber; + delete parameters.outCsvSort; + Object.assign(parameters, call.parameters); + parameters.out = ['/results.csv', ...call.outputs.map((output) => `${output}:/output/${output}.txt`)]; + module.FS.mkdir('/output'); + module.FS.writeFile('/parameter.json', JSON.stringify(document)); + const code = module.callMain(['--parameterFile=/parameter.json']); + if (code !== 0) return JSON.stringify({ error: `IntaRNA exited with status ${code}. Check the diagnostics and settings.` }); + const files = Object.fromEntries(call.outputs.map((output) => [output, module.FS.readFile(`/output/${output}.txt`, { encoding: 'utf8' })])); + return JSON.stringify({ csv: module.FS.readFile('/results.csv', { encoding: 'utf8' }), files }); + }; + const progress = (message) => self.postMessage({ type: 'status', message: String(message) }); + const payload = JSON.stringify({ + query: request.query.records[0].sequence, target: request.target.records[0].sequence, + qIdxPos0: request.parameters.qIdxPos0, tIdxPos0: request.parameters.tIdxPos0, + copomus: request.copomus, + }); + pyodide.globals.set('browser_request', payload); + pyodide.globals.set('browser_predict', predict); + pyodide.globals.set('browser_progress', progress); + const resultJson = await pyodide.runPythonAsync(` +from copomus.browser import run +await run(browser_request, browser_predict, browser_progress) +`); + const result = JSON.parse(String(resultJson)); + self.postMessage({ type: 'result', mutation: result, files: [], diagnostics: diagnostics.join('\n') }); + } catch (error) { + self.postMessage({ type: 'error', message: error instanceof Error ? error.message : String(error), diagnostics: diagnostics.join('\n') }); + } +}; diff --git a/gui/copomus.js b/gui/copomus.js new file mode 100644 index 00000000..db522bfd --- /dev/null +++ b/gui/copomus.js @@ -0,0 +1,122 @@ +import { downloadText } from './results.js'; +import { InteractionVisualization } from './visualization.js'; +export { COPOMUS_MEASURES, COPOMUS_FILTERS, COPOMUS_DEFAULTS, normalizeCopomus } from './copomus-options.js'; + +const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +const MODE_LABELS = Object.freeze({ + ww: 'Wild type', wm: 'Target mutant', mw: 'Query mutant', mm: 'Compensatory mutant', +}); + +class MutationVisualization { + constructor(root) { + this.root = root; + this.viewer = new InteractionVisualization(root, 'mutation-'); + this.controls = root.querySelector('[data-role=mutation-modes]'); + this.title = root.querySelector('[data-role=mutation-visualization-title]'); + for (const button of this.controls.querySelectorAll('button')) button.addEventListener('click', () => this.showMode(button.value)); + } + + clear() { + this.candidate = null; this.request = null; this.controls.hidden = true; + this.viewer.clear(); this.title.textContent = 'Compensatory-mutation visualization'; + } + + show(candidate, request) { + this.candidate = candidate; this.request = request; this.controls.hidden = false; + this.showMode(candidate.interactions.mm ? 'mm' : Object.keys(MODE_LABELS).find((mode) => candidate.interactions[mode]) ?? 'mm'); + } + + showMode(mode) { + for (const button of this.controls.querySelectorAll('button')) button.setAttribute('aria-pressed', String(button.value === mode)); + this.title.textContent = `${this.candidate.values.mutation}: ${MODE_LABELS[mode]}`; + const interaction = this.candidate.interactions[mode]; + if (!interaction) { + this.viewer.clear(); + this.viewer.status.textContent = `${MODE_LABELS[mode]} has no favorable reported interaction.`; + return; + } + const querySequence = mode[0] === 'w' ? this.candidate.queryWildtype : this.candidate.queryMutated; + const targetSequence = mode[1] === 'w' ? this.candidate.targetWildtype : this.candidate.targetMutated; + const visualRequest = { + ...this.request, + query: { ...this.request.query, records: [{ ...this.request.query.records[0], sequence: querySequence }] }, + target: { ...this.request.target, records: [{ ...this.request.target.records[0], sequence: targetSequence }] }, + }; + this.viewer.show(interaction, visualRequest); + } +} + +function numeric(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +export class CopomusResultsView { + constructor(root) { + this.root = root; this.candidates = []; this.key = 'rank'; this.direction = 1; + this.visualization = new MutationVisualization(root); + this.search = root.querySelector('[data-role=mutation-search]'); + this.table = root.querySelector('table'); this.header = this.table.querySelector('thead tr'); this.body = this.table.querySelector('tbody'); + this.count = root.querySelector('[data-role=mutation-visible-count]'); this.empty = root.querySelector('[data-role=mutation-no-matches]'); + this.picker = root.querySelector('[data-role=mutation-visible-columns]'); + this.search.addEventListener('input', () => this.render()); + root.querySelector('[data-role=mutation-download]').addEventListener('click', () => downloadText('copomus-mutations.tsv', this.csv)); + } + + clear() { + this.candidates = []; this.csv = ''; this.selected = null; this.body.replaceChildren(); this.root.hidden = true; this.visualization.clear(); + } + + show(result, request) { + if (!isObject(result) || typeof result.tsv !== 'string' || !Array.isArray(result.columns) || !Array.isArray(result.candidates) || result.columns.length < 6 || result.candidates.some((candidate) => !isObject(candidate.values) || !isObject(candidate.interactions))) throw new Error('The mutation-design engine returned malformed results.'); + this.csv = result.tsv; this.columns = [...result.columns]; this.candidates = result.candidates; this.request = request; + this.selected = null; this.visualization.clear(); this.visible = new Set(this.columns); + this.picker.replaceChildren(); + for (const key of this.columns) { + const label = document.createElement('label'), input = document.createElement('input'); + input.type = 'checkbox'; input.checked = true; input.value = key; + input.addEventListener('change', () => { if (input.checked) this.visible.add(key); else this.visible.delete(key); this.render(); }); + label.append(input, document.createTextNode(key)); this.picker.append(label); + } + this.search.value = ''; this.key = this.columns.includes('rank') ? 'rank' : this.columns[0]; this.direction = 1; + this.root.hidden = false; this.render(); return this.candidates.length; + } + + selectedCandidates() { + const query = this.search.value.trim().toLowerCase(); + return this.candidates.filter((candidate) => Object.values(candidate.values).some((value) => String(value).toLowerCase().includes(query))).toSorted((left, right) => { + const a = left.values[this.key], b = right.values[this.key], x = numeric(a), y = numeric(b); + if (x !== null && y !== null) return this.direction * (x - y); + if (x === null && y !== null) return 1; + if (x !== null && y === null) return -1; + return this.direction * String(a ?? '').localeCompare(String(b ?? '')); + }); + } + + render() { + const rows = this.selectedCandidates(), columns = this.columns.filter((key) => this.visible.has(key)); + this.header.replaceChildren(); + const viewHeading = document.createElement('th'); viewHeading.scope = 'col'; viewHeading.setAttribute('aria-label', 'Mutation visualization'); this.header.append(viewHeading); + for (const key of columns) { + const cell = document.createElement('th'), button = document.createElement('button'); cell.scope = 'col'; button.type = 'button'; button.textContent = key; + const active = key === this.key, direction = this.direction === 1 ? 'ascending' : 'descending'; + cell.setAttribute('aria-sort', active ? direction : 'none'); button.setAttribute('aria-label', `Sort by ${key}${active ? `, currently ${direction}` : ''}`); + button.addEventListener('click', () => { this.direction = active ? -this.direction : 1; this.key = key; this.render(); }); + cell.append(button); this.header.append(cell); + } + this.body.replaceChildren(); + for (const candidate of rows) { + const row = document.createElement('tr'); row.classList.toggle('selected', candidate === this.selected); + const action = document.createElement('td'), view = document.createElement('button'); view.type = 'button'; view.textContent = 'View'; view.className = 'view-interaction'; + view.setAttribute('aria-label', `Visualize mutation ${candidate.values.mutation}`); view.setAttribute('aria-pressed', String(candidate === this.selected)); + row.addEventListener('click', () => { + this.selected = candidate; this.visualization.show(candidate, this.request); + for (const other of this.body.children) { other.classList.toggle('selected', other === row); other.querySelector('.view-interaction').setAttribute('aria-pressed', String(other === row)); } + }); + action.append(view); row.append(action); + for (const key of columns) { const cell = document.createElement('td'); cell.textContent = String(candidate.values[key]); if (numeric(candidate.values[key]) !== null) cell.className = 'number'; row.append(cell); } + this.body.append(row); + } + this.count.textContent = `${rows.length} of ${this.candidates.length} mutations`; this.empty.hidden = rows.length > 0; + } +} diff --git a/gui/docs/copomus.md b/gui/docs/copomus.md new file mode 100644 index 00000000..5ed6a533 --- /dev/null +++ b/gui/docs/copomus.md @@ -0,0 +1,51 @@ +# CopomuS Mutation Design in the browser + +The **Mutation Design** task integrates the existing CopomuS scientific workflow +without a server or native subprocess. It accepts exactly one query and one +target FASTA record, normalizes DNA `T` to RNA `U`, and rejects ambiguous `N` +bases. CopomuS-specific controls live below the two sequence inputs: + +- ranking measures, with arrow controls for priority order; +- MFE or MFE-region/suboptimal candidate selection; +- composable base-pair, lonely-pair and helix-end filters; +- flipped-pair or all-pairing mutation generation; +- the existing alpha/beta separation thresholds; and +- optional query-first explicit encodings such as `G1C&C10G`. + +An explicit encoding replaces candidate selection, filters and generation, as in +the CLI. The advanced Seed, Model & Energy, Query and Target tabs configure the +IntaRNA calls. IntaRNA output-selection tabs are hidden because CopomuS chooses +the columns and position profiles required by its measures. + +## Runtime boundary + +`copomus-worker.js` owns the complete run so aborting terminates both runtimes. +It loads the checksum-pinned, same-origin Pyodide distribution and the staged +Python modules under `/python`. `copomus.browser.run()` reuses the CLI mutation, +filter, measure, comparison, ranking and TSV helpers. Only the process-launch +adapter changes: Python sends a bounded JSON request to the worker and awaits a +fresh IntaRNA WebAssembly instance. The worker validates this private protocol, +embeds the mutated sequences in an `IntaRNA-parameters-v1` document, and returns +CSV/profile text as JSON. No shell command, upload, CDN or external service is +used for computation. + +Wildtype output is reused across candidates, matching the CLI optimization. +Every candidate evaluates wildtype/wildtype, wildtype/target-mutant, +query-mutant/wildtype and compensatory-mutant pairs. Browser runs are capped at +100 generated mutations because each candidate can require several complete +folding runs; larger screens remain available through `python/CopomuS.py`. + +## Results and reproducibility + +The sortable/filterable table contains the complete extended CopomuS TSV. Select +**View** to switch the embedded vaRRI visualization between the four mutation +states; a missing favorable interaction is reported explicitly. Session ZIPs +contain `parameter.json` and `copomus-mutations.tsv`. The JSON keeps native +IntaRNA settings and records the task and CopomuS settings under `gui`, so Load +restores the complete editable run. As with other GUI sessions, all data stays +in the tab and disappears on reload unless downloaded. + +The WASM workflow vendors no generated runtime files in Git. Build staging +downloads Pyodide 314.0.7 from its npm release, verifies the pinned SHA-256, +stages only the core runtime/standard library and includes the MPL-2.0 notice. +`build-info.json` records its version and every staged Pyodide asset digest. diff --git a/gui/docs/json-workflow.md b/gui/docs/json-workflow.md index 1c2d9dee..bd1f8dee 100644 --- a/gui/docs/json-workflow.md +++ b/gui/docs/json-workflow.md @@ -31,7 +31,7 @@ replays the JSON in fresh engines and compares every output byte. Each successful computation, including a run with no interaction rows, creates a new session with a unique ID within the page and a default name such as -`Prediction 1` or `Evaluation 2`. The session retains the computed inputs in JSON, +`Prediction 1`, `Evaluation 2` or `Mutation Design 3`. The session retains the computed inputs in JSON, CSV output, additional files and diagnostics. Editing parameters, Load, examples, Reset, invalid inputs, failed computations and cancellation preserve prior sessions. Failed or aborted computations do not create a session. @@ -69,12 +69,12 @@ in the form for switching modes, but their values are omitted from executed and stored settings. Previously computed sessions remain available. Empty FASTA fields show the short demonstration sequences as placeholders only. The Examples disclosure menu follows the Input Parameters label and lists only examples for -the selected Prediction or Evaluation task. +the selected Prediction, Evaluation or Mutation Design task. Maintainers edit `gui/examples/index.json` (an array of local JSON filenames). Each listed file contains `name`, `description`, and `configuration`, where the -configuration is the same native JSON document used by Load. Two replaceable -examples cover prediction and evaluation. Loading validates their settings and +configuration is the same native JSON document used by Load. Three replaceable +examples cover prediction, evaluation and mutation design. Loading validates their settings and sequences through the same importer and computation validator. Missing or invalid examples show an error in the menu; they never overwrite the form. @@ -83,8 +83,8 @@ examples show an error in the menu; they never overwrite the form. Each strand has an Upload button and accepts a text-file drop into its textarea. Files are read locally (at most 1 MB), validated with the same FASTA checks as manual entry, and replace only that strand after successful validation. Prediction -accepts up to 50 FASTA records per strand, each 2–5,000 nt; evaluation requires -one record. The longest selected query × longest selected target must be at most +accepts up to 50 FASTA records per strand, each 2–5,000 nt; evaluation and +mutation design require one record. The longest selected query × longest selected target must be at most 1,000,000. Names end at the first whitespace; ignored header descriptions produce a warning below the corresponding field. Both FASTA fields scroll horizontally and vertically. Invalid files leave the diff --git a/gui/docs/step3-frontend-ui.md b/gui/docs/step3-frontend-ui.md index 4274ccb2..2ade8da2 100644 --- a/gui/docs/step3-frontend-ui.md +++ b/gui/docs/step3-frontend-ui.md @@ -31,7 +31,7 @@ when the viewport requires it. ```text Group logo IntaRNA package / RNA–RNA interaction analysis University logo RNA–RNA interactions Computations stay on your device -Task: Prediction / Evaluation +Task: Prediction / Evaluation / Mutation Design [ Sequences | Seed | Output | CSV | Model & Energy | Query | Target | CLI-only ] [ Active panel: e.g. query + target, start indices, evaluation structures ] Compute Input Parameters: Examples / Store / Load / Reset @@ -84,6 +84,11 @@ format and intended use, including the model restrictions shown in its label. Prediction accepts plain sequence or up to 50 FASTA records per strand. Evaluation requires one query and target and 1–100 predefined structures. +Mutation Design also requires one query and target, rejects ambiguous bases and +places CopomuS measures, candidate selection/filters, mutation generation, +thresholds and explicit mutation encodings in the Sequences panel. Its Output +and CSV tabs are hidden because CopomuS owns the extended TSV schema. See +[copomus.md](copomus.md) for the cross-runtime workflow and browser limit. Wrapped/lowercase sequence text and DNA T normalize to uppercase RNA U. A/C/G/U/N are accepted. Each record is 2–5,000 nt; seed predictions additionally require at least `seedBP` nucleotides. FASTA IDs end at the first whitespace, matching the CLI. Duplicate IDs (including collisions after diff --git a/gui/docs/step4-results-and-deployment.md b/gui/docs/step4-results-and-deployment.md index 451181bf..60d65b62 100644 --- a/gui/docs/step4-results-and-deployment.md +++ b/gui/docs/step4-results-and-deployment.md @@ -113,14 +113,14 @@ python3 scripts/wasm/test-browser.py http://127.0.0.1:8765 --browser firefox The staging script includes matched WASM assets, only the application source files needed at runtime, compiled version metadata and dependency notices. It also verifies and stages the pinned Plotly.js 4.1.2 Cartesian bundle and MIT -license; no CDN is used. Build-time `build-info.json` +license plus the Pyodide 314.0.7 core runtime and MPL-2.0 license; no CDN is used. Build-time `build-info.json` and `build-info.txt` record actual CLI IntaRNA/ViennaRNA/Boost versions, source commit and tracked modifications, SDK version and asset SHA-256 digests. Staging checks these digests and preserves the original metadata; it never replaces the compiled revision with a later staging checkout. The footer displays versions. It needs the same activated SDK and dependency build directory as the build. -No generated WASM artifacts are committed; the reviewed minified Plotly browser -distribution is the only new runtime artifact. HTTP(S), module workers and WebAssembly +No generated WASM or Pyodide artifacts are committed; both runtimes are staged +from checksum-verified build inputs. HTTP(S), module workers and WebAssembly are required; `file://` and legacy browsers are unsupported. No backend, SharedArrayBuffer, credentials, CDN or cross-origin headers are needed. CSP keeps scripts/styles same-origin and allows ephemeral blob images solely for diff --git a/gui/examples/index.json b/gui/examples/index.json index 947fd53d..1980f591 100644 --- a/gui/examples/index.json +++ b/gui/examples/index.json @@ -1 +1 @@ -["prediction.json", "evaluation.json"] +["prediction.json", "evaluation.json", "mutation-design.json"] diff --git a/gui/examples/mutation-design.json b/gui/examples/mutation-design.json new file mode 100644 index 00000000..471dbde1 --- /dev/null +++ b/gui/examples/mutation-design.json @@ -0,0 +1,27 @@ +{ + "name": "Compensatory duplex mutation", + "description": "Rank an explicit compensatory mutation for a short RNA duplex.", + "configuration": { + "schema": "IntaRNA-parameters-v1", + "parameters": { + "query": { + "text": ">query\nGGGAGGAGGG\n" + }, + "target": { + "text": ">target\nCCCUCCUCCC\n" + } + }, + "gui": { + "task": "mutation", + "copomus": { + "measures": ["mfeCover", "E", "minDeltaE"], + "candidateSelection": "mfe", + "candidateFilters": [], + "generator": "flip", + "mutationEncoding": "G1C&C10G", + "alpha": 1, + "beta": 1 + } + } + } +} diff --git a/gui/index.html b/gui/index.html index 275f8b1f..2e9bbca6 100644 --- a/gui/index.html +++ b/gui/index.html @@ -3,7 +3,7 @@ - + IntaRNA · RNA–RNA interactions @@ -32,6 +32,7 @@ Analysis task +

Predict interactions for all query–target sequence pairs.

@@ -50,6 +51,20 @@

Plain sequence or multi-FASTA (prediction only), A/C/G/U/T/N. Up to 50 records per field and 2–5,000 nt per record; longest selected query × longest selected target ≤ 1,000,000. FASTA names stop at the first whitespace. Seed predictions also require at least as many nucleotides as seed base pairs. Signed start indices apply to every record on that strand; negative indexing skips zero.

+ @@ -85,6 +100,22 @@ + diff --git a/gui/parameter-file.js b/gui/parameter-file.js index 7e52f341..376050e5 100644 --- a/gui/parameter-file.js +++ b/gui/parameter-file.js @@ -1,6 +1,7 @@ import { JSON_SCHEMA, CSV_COLUMNS, LIMITS, normalizeParameters } from './protocol.js'; import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS } from './options.js'; import { CSV_CATALOG } from './columns.js'; +import { COPOMUS_DEFAULTS, normalizeCopomus } from './copomus-options.js'; export const MAX_PARAMETER_BYTES = 5000000; const reports = { normal: 'N', detailed: 'D', ensemble: 'E' }; @@ -44,10 +45,16 @@ export function exportParameters(request, provenance) { const selectedReports = outputs.filter((key) => Object.hasOwn(reports, key)); const comments = [ ...provenance.trimEnd().split(/\r?\n/), - 'Reproduce: IntaRNA --parameterFile=parameter.json --out=intarna-interactions.csv', + request.task === 'mutation' + ? 'Reproduce the mutation workflow with python3 CopomuS.py and the embedded sequences/settings.' + : 'Reproduce: IntaRNA --parameterFile=parameter.json --out=intarna-interactions.csv', ...selectedReports.map((key) => `Additional report: IntaRNA --parameterFile=parameter.json --outMode=${reports[key]} --outCsvCols=${CSV_COLUMNS} --outCsvSort '' --out=intarna-${key}.txt`), ]; const gui = {}; + if (request.task === 'mutation') { + gui.task = 'mutation'; + gui.copomus = normalizeCopomus(request.copomus); + } if (selectedReports.length) gui.reports = selectedReports; if (normalized.outSpotProbSpots) gui.outSpotProbSpots = normalized.outSpotProbSpots; return { schema: JSON_SCHEMA, _comment: comments, parameters, ...(Object.keys(gui).length ? { gui } : {}) }; @@ -64,7 +71,7 @@ export function importParameters(source) { const document = typeof source === 'string' ? JSON.parse(source.replace(/^\uFEFF/, '')) : source; if (!object(document) || document.schema !== JSON_SCHEMA || !object(document.parameters)) throw new Error(`Expected a ${JSON_SCHEMA} parameter document.`); for (const key of Object.keys(document)) if (!['schema', '_comment', 'parameters', 'gui'].includes(key)) throw new Error(`Unknown JSON document field: ${key}`); - const request = { task: 'prediction', query: '', target: '', qIdxPos0: 1, tIdxPos0: 1, rri: '', parameters: {}, outputs: [], csvColumns: CSV_COLUMNS.split(',') }; + const request = { task: 'prediction', query: '', target: '', qIdxPos0: 1, tIdxPos0: 1, rri: '', parameters: {}, outputs: [], csvColumns: CSV_COLUMNS.split(','), copomus: { ...COPOMUS_DEFAULTS, measures: [...COPOMUS_DEFAULTS.measures], candidateFilters: [] } }; const known = new Set(FIELDS.map((field) => field.key)); for (const [key, value] of Object.entries(document.parameters)) { if (value === null) throw new Error(`${key}: null is not a parameter value.`); @@ -98,8 +105,14 @@ export function importParameters(source) { } if (document.gui !== undefined) { if (!object(document.gui)) throw new Error('gui must be an object.'); + if (document.gui.task !== undefined) { + if (document.gui.task !== 'mutation' || request.task === 'evaluation') throw new Error('gui.task must select Mutation Design without an IntaRNAeval personality.'); + request.task = 'mutation'; + } for (const [key, value] of Object.entries(document.gui)) { - if (key === 'reports' && Array.isArray(value) && value.every((entry) => Object.hasOwn(reports, entry))) request.outputs.push(...value); + if (key === 'task') continue; + if (key === 'copomus') request.copomus = normalizeCopomus(value); + else if (key === 'reports' && Array.isArray(value) && value.every((entry) => Object.hasOwn(reports, entry))) request.outputs.push(...value); else if (key === 'outSpotProbSpots') { if (request.parameters.outSpotProbSpots && request.parameters.outSpotProbSpots !== value) throw new Error('Conflicting spotProb selections.'); request.parameters.outSpotProbSpots = text(value, key, 10000); @@ -107,10 +120,13 @@ export function importParameters(source) { } } selections(request); + if (request.task === 'mutation' && request.outputs.length) throw new Error('Mutation Design parameter files cannot request IntaRNA additional outputs.'); + if (request.task !== 'mutation' && document.gui?.copomus !== undefined) throw new Error('CopomuS settings require gui.task Mutation Design.'); // Keep prediction-only controls from being silently ignored on import. if (request.task === 'evaluation' && FIELDS.some((field) => field.prediction && Object.hasOwn(request.parameters, field.key))) throw new Error('Evaluation documents must omit prediction-only parameters.'); const normalized = normalizeParameters(request); request.qIdxPos0 = normalized.qIdxPos0; request.tIdxPos0 = normalized.tIdxPos0; request.parameters = normalized; + if (request.task === 'mutation') request.copomus = normalizeCopomus(request.copomus); return request; } diff --git a/gui/protocol.js b/gui/protocol.js index d4c71c2d..e5598e5c 100644 --- a/gui/protocol.js +++ b/gui/protocol.js @@ -1,5 +1,6 @@ import { FIELDS, PARAMETER_DEFAULTS, EXTRA_OUTPUTS, ENSEMBLE_COLUMNS } from './options.js'; import { CSV_CATALOG } from './columns.js'; +import { normalizeCopomus } from './copomus-options.js'; // Bound the largest pair's matrix while allowing batches of shorter sequences. export const LIMITS = Object.freeze({ minLength: 2, maxLength: 5000, maxRecords: 50, maxPairs: 1000000, maxFastaBytes: 1000000 }); @@ -25,7 +26,7 @@ export function parseFasta(text, label, multiple = true) { current.sequence += line.replace(/\s/g, '').toUpperCase().replaceAll('T', 'U'); } } - if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 50 FASTA records' : 'evaluation requires exactly one FASTA record'}.`); + if ((!multiple && records.length !== 1) || records.length > LIMITS.maxRecords) throw new Error(`${label}: ${multiple ? 'use at most 50 FASTA records' : 'this task requires exactly one FASTA record'}.`); const ids = new Set(); for (const record of records) { if (!record.sequence || /[^ACGUN]/.test(record.sequence)) throw new Error(`${label}: use only A, C, G, U, T or N in every sequence.`); @@ -111,7 +112,7 @@ export function validateInteractions(encoding, query, target, qStart, tStart, se // Validate scalar controls independently so unfinished forms can be saved/loaded. export function normalizeParameters(request) { - if (!['prediction', 'evaluation'].includes(request.task ?? 'prediction')) throw new Error('Choose prediction or evaluation.'); + if (!['prediction', 'evaluation', 'mutation'].includes(request.task ?? 'prediction')) throw new Error('Choose prediction, evaluation or Mutation Design.'); const evaluating = request.task === 'evaluation'; const parameters = {}; const supplied = request.parameters ?? {}; @@ -142,10 +143,15 @@ export function normalizeParameters(request) { export function prepareRequest(request) { const task = request?.task ?? 'prediction'; - if (!['prediction', 'evaluation'].includes(task)) throw new Error('Choose prediction or evaluation.'); + if (!['prediction', 'evaluation', 'mutation'].includes(task)) throw new Error('Choose prediction, evaluation or Mutation Design.'); const evaluating = task === 'evaluation'; - const query = parseFasta(request.query, 'Query', !evaluating), target = parseFasta(request.target, 'Target', !evaluating); + const mutationDesign = task === 'mutation'; + const query = parseFasta(request.query, 'Query', task === 'prediction'), target = parseFasta(request.target, 'Target', task === 'prediction'); const parameters = normalizeParameters(request); + const copomus = mutationDesign ? normalizeCopomus(request.copomus, { + query: query.records[0], target: target.records[0], + qIdxPos0: parameters.qIdxPos0, tIdxPos0: parameters.tIdxPos0, + }) : undefined; const qRecords = selectedRecords(query, parameters.qSet, 'qSet'), tRecords = selectedRecords(target, parameters.tSet, 'tSet'); if (parameters.outPairwise && qRecords.length !== tRecords.length) throw new Error('Pairwise output requires the same number of selected query and target records.'); const work = Math.max(...qRecords.map((q) => q.sequence.length)) * Math.max(...tRecords.map((t) => t.sequence.length)); @@ -180,6 +186,7 @@ export function prepareRequest(request) { if (parameters.outCsvSort && !csvColumns.includes(parameters.outCsvSort)) throw new Error('The CSV sort column must be selected for output.'); const outputs = request.outputs ?? []; if (!Array.isArray(outputs) || new Set(outputs).size !== outputs.length || outputs.some((key) => !EXTRA_OUTPUTS.some((entry) => entry.key === key && (!evaluating || !entry.prediction)))) throw new Error('Choose available additional output formats for this task.'); + if (mutationDesign && outputs.length) throw new Error('Mutation Design produces its own extended CopomuS output; disable IntaRNA additional output files.'); if (outputs.includes('ensemble') && (qRecords.length !== 1 || tRecords.length !== 1)) throw new Error('Ensemble reports require one query and one target.'); const needsEnsemble = csvColumns.some((c) => ENSEMBLE_COLUMNS.includes(c)) || outputs.some((c) => ['ensemble', 'spotProb', 'qSpotProb', 'tSpotProb'].includes(c)); if (evaluating) { @@ -215,7 +222,7 @@ export function prepareRequest(request) { } } } - return { task, query, target, parameters, csvColumns, outputs, rri: evaluating ? request.rri : '' }; + return { task, query, target, parameters, csvColumns, outputs, rri: evaluating ? request.rri : '', ...(copomus ? { copomus } : {}) }; } export function makeArguments(request, format = 'C') { diff --git a/gui/runner.js b/gui/runner.js index 91ccc6b2..4ac79ecc 100644 --- a/gui/runner.js +++ b/gui/runner.js @@ -1,6 +1,6 @@ // The main thread stays available even while synchronous C++ is calculating. export class PredictionRunner { - constructor(workerFactory = () => new Worker(new URL('./intarna-worker.js', import.meta.url), { type: 'module' })) { + constructor(workerFactory = (request) => new Worker(new URL(request.task === 'mutation' ? './copomus-worker.js' : './intarna-worker.js', import.meta.url), { type: 'module' })) { this.workerFactory = workerFactory; this.worker = null; this.cancel = null; @@ -9,7 +9,7 @@ export class PredictionRunner { run(request, onStatus = () => {}) { if (this.worker) return Promise.reject(new Error('A prediction is already running.')); return new Promise((resolve, reject) => { - const worker = this.workerFactory(); + const worker = this.workerFactory(request); this.worker = worker; const finish = (error, result) => { if (this.worker !== worker) return; // Ignore queued messages after abort. @@ -32,7 +32,7 @@ export class PredictionRunner { }; worker.onerror = (event) => { event.preventDefault(); - finish(new Error('Could not load or run the prediction engine. Reload the page and check that intarna.js and intarna.wasm are served together.')); + finish(new Error('Could not load or run the local analysis engine. Reload the page and check that the staged IntaRNA, Pyodide and Python assets are served together.')); }; worker.onmessageerror = () => finish(new Error('Could not read the prediction engine response.')); try { diff --git a/gui/sessions.js b/gui/sessions.js index ed3c873a..f7fa033c 100644 --- a/gui/sessions.js +++ b/gui/sessions.js @@ -2,6 +2,7 @@ import { ResultsView, downloadBlob } from './results.js'; import { serializeParameters, importParameters } from './parameter-file.js'; import { zipFiles } from './archive.js'; import { AdditionalOutputView } from './output-visualization.js'; +import { CopomusResultsView } from './copomus.js'; // Sessions live only in this page. Each owns a JSON snapshot and its own view; // editing the form or exploring another session cannot change an earlier run. @@ -47,13 +48,16 @@ export class SessionsView { const parameterJson = serializeParameters({ ...request, qIdxPos0: request.parameters.qIdxPos0, tIdxPos0: request.parameters.tIdxPos0 }, provenance); const root = this.template.content.firstElementChild.cloneNode(true); const find = (role) => root.querySelector(`[data-role=${role}]`); - const id = `session-${this.nextNumber}`, task = request.task === 'evaluation' ? 'Evaluation' : 'Prediction'; + const id = `session-${this.nextNumber}`, task = request.task === 'evaluation' ? 'Evaluation' : request.task === 'mutation' ? 'Mutation Design' : 'Prediction'; + const mutation = request.task === 'mutation'; const session = { id, name: `${task} ${this.nextNumber}`, parameterJson, root, - panel: find('panel'), toggle: find('toggle'), view: new ResultsView(find('results-view')), fileViews: [], + panel: find('panel'), toggle: find('toggle'), + view: mutation ? new CopomusResultsView(find('mutation-results-view')) : new ResultsView(find('results-view')), + fileViews: [], }; - const count = session.view.show(result.csv, result.csvColumns, request); - const summary = count ? `${task} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && request.parameters.outNumber === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; + const count = mutation ? session.view.show(result.mutation, request) : session.view.show(result.csv, result.csvColumns, request); + const summary = mutation ? `Mutation Design complete: ${count} ranked mutation${count === 1 ? '' : 's'}.` : count ? `${task} complete: ${count} interaction${count === 1 ? '' : 's'}.` : request.task === 'prediction' && request.parameters.outNumber === 0 ? 'Accessibility computation complete. No interaction rows requested.' : 'No favorable interactions found with these sequences and settings.'; find('summary').textContent = summary; root.dataset.sessionId = id; session.panel.id = `${id}-panel`; session.toggle.setAttribute('aria-controls', session.panel.id); @@ -93,7 +97,7 @@ export class SessionsView { // filtered table, or the session selected at download time. find('download-zip').addEventListener('click', () => { try { - const files = [{ name: 'parameter.json', text: parameterJson }, { name: 'intarna-interactions.csv', text: session.view.csv }, ...result.files]; + const files = [{ name: 'parameter.json', text: parameterJson }, { name: mutation ? 'copomus-mutations.tsv' : 'intarna-interactions.csv', text: session.view.csv }, ...result.files]; const filename = session.name.replace(/[^\p{L}\p{N}._-]+/gu, '-').replace(/^[.-]+|[.-]+$/g, '').slice(0, 80) || id; downloadBlob(`${filename}.zip`, new Blob([zipFiles(files)], { type: 'application/zip' })); } catch (error) { this.reportError(new Error(`ZIP download failed: ${error.message}`)); } diff --git a/gui/styles.css b/gui/styles.css index ea60e124..351c69c0 100644 --- a/gui/styles.css +++ b/gui/styles.css @@ -356,6 +356,50 @@ fieldset { margin-top:20px; } +.mutation-input { + margin-top:20px; + padding-top:18px; + border-top:1px solid var(--border); +} + +.mutation-option-grid { + display:grid; + grid-template-columns:repeat(2,minmax(0,1fr)); + gap:16px; + margin:10px 0 14px; +} + +.mutation-option-grid fieldset { + border:1px solid var(--border); + border-radius:5px; + padding:8px 12px; +} + +.mutation-option-grid legend { + font-weight:600; + font-size:.9rem; +} + +.measure-choice { + display:flex; + align-items:center; + justify-content:space-between; + gap:6px; +} + +.measure-priority { + display:flex; +} + +.measure-priority .text-button { + padding:.05rem .25rem; + text-decoration:none; +} + +#copomus-mutation-encoding { + min-height:5rem; +} + .field { display:flex; flex-direction:column; @@ -463,7 +507,7 @@ fieldset { font-size:.9rem; } -[data-role=visible-count] { +[data-role=visible-count],[data-role=mutation-visible-count] { font-size:.85rem; color:var(--muted); } @@ -563,7 +607,7 @@ tbody tr.selected { background:var(--table-selection); } gap:16px; } -[data-role=varri-frame] { +[data-role=varri-frame],[data-role=mutation-varri-frame] { display:block; width:100%; height:600px; @@ -573,12 +617,31 @@ tbody tr.selected { background:var(--table-selection); } background:white; } -[data-role=visualization-status] { +[data-role=visualization-status],[data-role=mutation-visualization-status] { max-width:none; overflow-wrap:anywhere; font-size:.9rem; } +.mutation-modes { + display:flex; + flex-wrap:wrap; + gap:8px; + margin-bottom:12px; +} + +.mutation-modes button { + padding:.35rem .7rem; + background:var(--surface); + color:var(--brand-blue-dark); +} + +.mutation-modes button[aria-pressed=true] { + color:white; + background:var(--brand-blue); + border-color:var(--brand-blue); +} + [data-role=output-files] { margin-top:24px; } @@ -813,7 +876,7 @@ dialog::backdrop { .field-grid { grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr)); } - .field-grid[class*=columns-],.accessibility-columns { grid-template-columns:1fr; } + .field-grid[class*=columns-],.accessibility-columns,.mutation-option-grid { grid-template-columns:1fr; } .seed-primary-row { grid-template-columns:1fr; } #csv-options .field-grid.columns-1 { grid-template-columns:1fr; } .results { diff --git a/gui/tests/parameter-file.test.mjs b/gui/tests/parameter-file.test.mjs index 25f9ba6d..40357c6b 100644 --- a/gui/tests/parameter-file.test.mjs +++ b/gui/tests/parameter-file.test.mjs @@ -9,6 +9,7 @@ test('JSON round trip retains embedded inputs, typed options, selected columns a for (const request of [ { ...DEFAULTS, query: '>q1\nGGGAGGAGGG\n>q2\nGGGAGGAGGG', qIdxPos0: -3, tIdxPos0: -5, parameters: { temperature: 22, noSeed: true, mode: 'M', qRegion: '-3-7', outSpotProbSpots: '-5&-3' }, csvColumns: ['E', 'id2'], outputs: ['normal', 'qPu', 'spotProb'] }, { ...DEFAULTS, task: 'evaluation', rri: EVALUATION_EXAMPLE, parameters: { qShape: '1 0.2\n2 0.3' }, outputs: ['detailed', 'ensemble'] }, + { ...DEFAULTS, task: 'mutation', qIdxPos0: -3, tIdxPos0: -5, copomus: { measures: ['EDqi', 'E'], candidateSelection: 'mfeSO', candidateFilters: ['GU'], generator: 'any', mutationEncoding: 'G-3C&C5G', alpha: 2, beta: 3 } }, ]) { const document = exportParameters(request, provenance); const imported = importParameters(JSON.stringify(document)); @@ -47,12 +48,12 @@ test('invalid/native-only settings are rejected before replacing the form', () = test('maintainer examples use the same JSON import and prediction validation', async () => { const { readFile } = await import('node:fs/promises'); const examples = JSON.parse(await readFile(new URL('../examples/index.json', import.meta.url))); - assert.equal(examples.length, 2); + assert.equal(examples.length, 3); const tasks = new Set(); for (const file of examples) { const example = JSON.parse(await readFile(new URL(`../examples/${file}`, import.meta.url))); assert.ok(example.name && example.description); tasks.add(prepareRequest(importParameters(example.configuration)).task); } - assert.deepEqual([...tasks].sort(), ['evaluation', 'prediction']); + assert.deepEqual([...tasks].sort(), ['evaluation', 'mutation', 'prediction']); }); diff --git a/gui/tests/protocol.test.mjs b/gui/tests/protocol.test.mjs index 4fa301e6..830242ae 100644 --- a/gui/tests/protocol.test.mjs +++ b/gui/tests/protocol.test.mjs @@ -49,6 +49,23 @@ test('evaluation validates antiparallel supplied structures in signed coordinate assert.throws(() => prepareRequest({ ...evalRequest, outputs: ['bpsvg'] }), /available/); }); +test('Mutation Design accepts one unambiguous pair and validates CopomuS settings', () => { + const mutation = request({}, { task: 'mutation', copomus: { + measures: ['E', 'minDeltaE'], candidateSelection: 'mfe', candidateFilters: ['GU'], + generator: 'flip', mutationEncoding: 'G1C&C10G', alpha: 1, beta: 2, + } }); + const prepared = prepareRequest(mutation); + assert.equal(prepared.task, 'mutation'); + assert.equal(prepared.query.records.length, 1); + assert.equal(prepared.copomus.mutationEncoding, 'G1C&C10G'); + assert.deepEqual(prepared.copomus.measures, ['E', 'minDeltaE']); + assert.throws(() => prepareRequest({ ...mutation, query: '>one\nGGGAGGAGGG\n>two\nGGGAGGAGGG' }), /exactly one/); + assert.throws(() => prepareRequest({ ...mutation, query: 'GGNAGGAGGG' }), /unambiguous/); + assert.throws(() => prepareRequest({ ...mutation, copomus: { ...mutation.copomus, mutationEncoding: 'A1C&C10G' } }), /does not match/); + assert.throws(() => prepareRequest({ ...mutation, copomus: { ...mutation.copomus, measures: [] } }), /measures/); + assert.throws(() => prepareRequest({ ...mutation, outputs: ['qAcc'] }), /own extended/); +}); + test('cross-option constraints, regions, probabilities, SHAPE and local file arguments', () => { for (const parameters of [{ noSeed: true, mode: 'S' }, { noSeed: true, outNumberPerSeed: 1 }, { mode: 'K', model: 'P' }, { model: 'B', mode: 'M' }, { model: 'B', helixMaxBP: 4 }, { qRegion: '8-2' }, { qAccConstr: 'b:1-20' }, { qAccConstr: 'bbb' }, { qShapeMethod: 'X' }, { tShapeConversion: 'bad' }, { qShape: '1 nope' }, { qAcc: 'P' }, { qAcc: 'P', qAccFile: 'bad' }, { outSpotProbSpots: '20&1' }, { windowWidth: 100 }, { qIntLenMax: 3 }, { qAcc: 'N', qRegionLenMax: 20 }, { qRegion: '1-10', qRegionLenMax: 20 }, { seedTQ: '1.|||||||||&1|||||||||.' }]) assert.throws(() => prepareRequest(request(parameters))); assert.doesNotThrow(() => prepareRequest(request({ noSeed: true, qAccConstr: 'b:1-2,p:8-9', qRegion: '1-10' }))); diff --git a/gui/uploads.js b/gui/uploads.js index 81748047..8a852463 100644 --- a/gui/uploads.js +++ b/gui/uploads.js @@ -4,6 +4,6 @@ import { parseFasta, LIMITS } from './protocol.js'; export async function readFastaFile(file, label, task) { if (!file || file.size > LIMITS.maxFastaBytes) throw new Error(`${label}: choose a FASTA text file of at most 1 MB.`); const text = await file.text(); - parseFasta(text, label, task !== 'evaluation'); + parseFasta(text, label, task === 'prediction'); return text; } diff --git a/gui/visualization.js b/gui/visualization.js index 7e9c46f0..1d18ad81 100644 --- a/gui/visualization.js +++ b/gui/visualization.js @@ -27,9 +27,9 @@ export function visualizationUrl(row, request) { } export class InteractionVisualization { - constructor(root) { - this.frame = root.querySelector('[data-role=varri-frame]'); this.link = root.querySelector('[data-role=varri-link]'); - this.status = root.querySelector('[data-role=visualization-status]'); + constructor(root, rolePrefix = '') { + this.frame = root.querySelector(`[data-role=${rolePrefix}varri-frame]`); this.link = root.querySelector(`[data-role=${rolePrefix}varri-link]`); + this.status = root.querySelector(`[data-role=${rolePrefix}visualization-status]`); } clear() { this.frame.removeAttribute('src'); this.frame.hidden = true; diff --git a/python/CopomuS.py b/python/CopomuS.py index d2460fc9..f06252c9 100644 --- a/python/CopomuS.py +++ b/python/CopomuS.py @@ -20,6 +20,7 @@ from copomus.candidate_selectors import get_selector from copomus.candidate_filters import get_filter from copomus.mutation_generators import get_generator +from copomus.workflow import calculate_ranks, write_csv from intarna_tools import atomic_text_output, nonnegative_integer, read_sequence @@ -186,28 +187,7 @@ def run_tests(self, mutations: List[Mutation]): measure.post_compute() def calculate_ranks(self, mutations: List[Mutation]): - # Calculate total ranking order - rank = 1 - mutations[0].ranks['total'] = rank - for i in range(1, len(mutations)): - if mutations[i - 1] < mutations[i]: - rank += 1 - mutations[i].ranks['total'] = rank - - # Calculate ranking for each measurement - for i in range(len(self.measures)): - rank = 1 - m_id = self.measures[i] - - measures = [(x.measures[i], j) for j, x in enumerate(mutations)] - measures.sort(key=lambda x: x[0]) - - mutations[measures[0][1]].ranks[m_id] = rank - for j in range(1, len(measures)): - measure, mut_id = measures[j] - if measures[j - 1][0] < measures[j][0]: - rank += 1 - mutations[mut_id].ranks[m_id] = rank + calculate_ranks(mutations, self.measures) def output_csv(self, mutations: List[Mutation]): # Open a destination only after all predictions and ranking succeeded. @@ -218,26 +198,7 @@ def output_csv(self, mutations: List[Mutation]): self.write_csv(mutations, sys.stdout) def write_csv(self, mutations: List[Mutation], handle): - # CREATE CSV WRITER - writer = csv.writer(handle, delimiter=self.delimiter, lineterminator="\n") - - # WRITE CSV - headers = [j for i in - [[f'{m.id}_rank'] + [f'{m.id}_{k}' for k in m.score.keys()] for m in mutations[0].measures] - for j in i] - writer.writerow(['mutation', 'rank', 'qIndex', 'tIndex', 'bpWildtype', 'bpMutated'] + headers) - - for m in mutations: - values = [] - for x in m.measures: - values.append(m.ranks[x.id]) - scores = list(x.score.values()) - for i in range(len(scores)): # replace None with 'NA' - if scores[i] is None: - scores[i] = 'NA' - values += scores - # values = [j for i in [list(x.score.values()) for x in r.measures] for j in i] - writer.writerow([str(m), m.ranks['total'], m.query_n, m.target_n, m.bp_ww, m.bp_mm] + values) # write csv + write_csv(mutations, handle, self.delimiter) def main(argv=None): diff --git a/python/copomus/Makefile.am b/python/copomus/Makefile.am index 0dc9467e..42d9c2d5 100644 --- a/python/copomus/Makefile.am +++ b/python/copomus/Makefile.am @@ -3,11 +3,13 @@ dist_copomus_DATA = \ __init__.py \ candidate_filters.py \ candidate_selectors.py \ + browser.py \ indexing.py \ IntaRNA.py \ measures.py \ mutation.py \ - mutation_generators.py + mutation_generators.py \ + workflow.py copomusdir = $(bindir)/copomus diff --git a/python/copomus/__init__.py b/python/copomus/__init__.py index 04b7a3e9..becbe3ec 100644 --- a/python/copomus/__init__.py +++ b/python/copomus/__init__.py @@ -1 +1 @@ -__all__ = ['mutation', 'indexing', 'IntaRNA', 'measures'] +__all__ = ['mutation', 'indexing', 'IntaRNA', 'measures', 'workflow', 'browser'] diff --git a/python/copomus/browser.py b/python/copomus/browser.py new file mode 100644 index 00000000..f9d7c988 --- /dev/null +++ b/python/copomus/browser.py @@ -0,0 +1,187 @@ +# Copyright 2026 +# Author: Alexander Mitrofanov + +"""Asynchronous CopomuS workflow for a JSON-to-WASM browser boundary.""" + +import io +import json +import math + +from copomus.IntaRNA import IntaRNA +from copomus.candidate_filters import get_filter +from copomus.mutation import parse_mutation_encoding +from copomus.mutation_generators import get_generator +from copomus.workflow import calculate_ranks, table, write_csv + + +MEASURES = ('E', 'minDeltaE', 'mfeCover', 'EDqi', 'cEDqi', 'Eqi', 'Eti') +SELECTORS = ('mfe', 'mfeSO') +FILTERS = ('GU', 'AU', 'GC', 'lp', 'lpMfe', 'he', 'heMfe') +GENERATORS = ('flip', 'any') +MAX_CANDIDATES = 100 +CONTEXT_COLUMNS = ('id1', 'start1', 'end1', 'id2', 'start2', 'end2', + 'subseqDP', 'hybridDP', 'E') + + +async def _call(predict, query, target, request, *, columns, number=1, + outputs=(), parameters=None): + payload = { + 'query': query, + 'target': target, + 'qIdxPos0': request['qIdxPos0'], + 'tIdxPos0': request['tIdxPos0'], + 'columns': list(dict.fromkeys(columns)), + 'outNumber': number, + 'outputs': list(dict.fromkeys(outputs)), + 'parameters': parameters or {}, + } + response = json.loads(await predict(json.dumps(payload, separators=(',', ':')))) + if response.get('error'): + raise RuntimeError(response['error']) + rows = IntaRNA.output_to_dict(response.get('csv', '')) + return rows, response.get('files', {}) + + +def _base_pairs(rows): + candidates = [] + seen = set() + for row in rows or []: + for pair in (row.get('bpList') or '').split(':'): + if not pair: + continue + target_index, query_index = [int(value) for value in pair.strip('()').split(',')] + candidate = (query_index, target_index) + if candidate not in seen: + seen.add(candidate) + candidates.append(candidate) + return candidates + + +async def _select_candidates(request, predict): + options = request['copomus'] + if options['mutationEncoding']: + return [(query, target) for query, target, _ in parse_mutation_encoding( + options['mutationEncoding'], request['query'], request['target'], + request['qIdxPos0'], request['tIdxPos0'])] + + if options['candidateSelection'] == 'mfe': + rows, _ = await _call(predict, request['query'], request['target'], request, + columns=['bpList']) + return _base_pairs(rows) + + optimum, _ = await _call( + predict, request['query'], request['target'], request, + columns=['start1', 'end1', 'start2', 'end2']) + if not optimum: + return [] + optimum = optimum[0] + rows, _ = await _call( + predict, request['query'], request['target'], request, + columns=['bpList'], number=1000, + parameters={'qRegion': f"{optimum['start2']}-{optimum['end2']}", + 'tRegion': f"{optimum['start1']}-{optimum['end1']}"}) + return _base_pairs(rows) + + +def _validate(request): + required = {'query', 'target', 'qIdxPos0', 'tIdxPos0', 'copomus'} + if not isinstance(request, dict) or not required <= request.keys(): + raise ValueError('Incomplete mutation-design request') + options = request['copomus'] + if (not isinstance(options, dict) + or not isinstance(options.get('measures'), list) + or not options['measures'] + or len(set(options['measures'])) != len(options['measures']) + or any(measure not in MEASURES for measure in options['measures'])): + raise ValueError('Choose one or more available CopomuS measures') + if options.get('candidateSelection') not in SELECTORS: + raise ValueError('Choose an available candidate-selection method') + if (not isinstance(options.get('candidateFilters'), list) + or len(set(options['candidateFilters'])) != len(options['candidateFilters']) + or any(candidate_filter not in FILTERS + for candidate_filter in options['candidateFilters'])): + raise ValueError('Choose available candidate filters without duplicates') + if options.get('generator') not in GENERATORS: + raise ValueError('Choose an available mutation generator') + if not isinstance(options.get('mutationEncoding'), str): + raise ValueError('Mutation encoding must be text') + if not all(isinstance(options.get(key), (int, float)) + and math.isfinite(options[key]) for key in ('alpha', 'beta')): + raise ValueError('alpha and beta must be finite numbers') + + +async def run(request_json, predict, progress=lambda _message: None): + """Run CopomuS using an awaitable JSON predictor supplied by JavaScript.""" + request = json.loads(request_json) + _validate(request) + options = request['copomus'] + progress('Selecting compensatory-mutation candidates…') + base_pairs = await _select_candidates(request, predict) + if not options['mutationEncoding']: + for candidate_filter in options['candidateFilters']: + base_pairs = get_filter(candidate_filter)( + base_pairs, request['query'], request['target'], + request['qIdxPos0'], request['tIdxPos0']) + if not base_pairs: + raise ValueError('There are no base-pair candidates left after filtering') + + if options['mutationEncoding']: + mutations = get_generator('specific')( + request['query'], request['target'], request['qIdxPos0'], + request['tIdxPos0'], options['measures'], options['alpha'], + options['beta'], options['mutationEncoding']) + else: + mutations = get_generator(options['generator'])( + request['query'], request['target'], request['qIdxPos0'], + request['tIdxPos0'], base_pairs, options['measures'], + options['alpha'], options['beta']) + if len(mutations) > MAX_CANDIDATES: + raise ValueError( + f'This browser run produced {len(mutations)} mutations; limit the ' + f'candidate set to at most {MAX_CANDIDATES} with filters or an explicit encoding') + + wildtype = None + for mutation_index, mutation in enumerate(mutations, 1): + progress(f'Evaluating mutation {mutation_index} of {len(mutations)}…') + columns = list(dict.fromkeys( + [column for measure in mutation.measures for column in measure.outcsvcols] + + list(CONTEXT_COLUMNS))) + outputs = list(dict.fromkeys( + output for measure in mutation.measures for output in measure.out_pipes)) + mutation_visualizations = {} + for mode in ('ww', 'wm', 'mw', 'mm'): + if mode == 'ww' and wildtype is not None: + rows, profiles = wildtype + else: + query = mutation.qw if mode[0] == 'w' else mutation.qm + target = mutation.tw if mode[1] == 'w' else mutation.tm + rows, profiles = await _call( + predict, query, target, request, columns=columns, outputs=outputs) + if mode == 'ww': + wildtype = rows, profiles + data = rows[0] if rows else False + mutation_visualizations[mode] = data or None + for measure in mutation.measures: + measure.profiles = profiles + measure.calculate_score(data, mode, mutation, '') + for measure in mutation.measures: + measure.post_compute() + mutation.browser_visualizations = mutation_visualizations + + mutations.sort() + calculate_ranks(mutations, options['measures']) + header, rows = table(mutations) + output = io.StringIO() + write_csv(mutations, output) + candidates = [] + for mutation, values in zip(mutations, rows): + candidates.append({ + 'values': dict(zip(header, values)), + 'queryWildtype': mutation.qw, + 'targetWildtype': mutation.tw, + 'queryMutated': mutation.qm, + 'targetMutated': mutation.tm, + 'interactions': mutation.browser_visualizations, + }) + return json.dumps({'tsv': output.getvalue(), 'columns': header, + 'candidates': candidates}, separators=(',', ':')) diff --git a/python/copomus/workflow.py b/python/copomus/workflow.py new file mode 100644 index 00000000..378d5050 --- /dev/null +++ b/python/copomus/workflow.py @@ -0,0 +1,53 @@ +# Copyright 2026 +# Author: Alexander Mitrofanov + +"""Shared CopomuS ranking and tabular output helpers.""" + +import csv + + +def calculate_ranks(mutations, measure_ids): + """Populate total and per-measure ranks after mutations were sorted.""" + rank = 1 + mutations[0].ranks['total'] = rank + for index in range(1, len(mutations)): + if mutations[index - 1] < mutations[index]: + rank += 1 + mutations[index].ranks['total'] = rank + + for measure_index, measure_id in enumerate(measure_ids): + rank = 1 + measures = [(mutation.measures[measure_index], index) + for index, mutation in enumerate(mutations)] + measures.sort(key=lambda entry: entry[0]) + mutations[measures[0][1]].ranks[measure_id] = rank + for index in range(1, len(measures)): + measure, mutation_index = measures[index] + if measures[index - 1][0] < measure: + rank += 1 + mutations[mutation_index].ranks[measure_id] = rank + + +def table(mutations): + """Return the stable CopomuS header and rows for ranked mutations.""" + headers = [column for measure in mutations[0].measures + for column in ([f'{measure.id}_rank'] + + [f'{measure.id}_{key}' for key in measure.score])] + header = ['mutation', 'rank', 'qIndex', 'tIndex', 'bpWildtype', 'bpMutated', *headers] + rows = [] + for mutation in mutations: + values = [] + for measure in mutation.measures: + values.append(mutation.ranks[measure.id]) + values.extend('NA' if value is None else value for value in measure.score.values()) + rows.append([str(mutation), mutation.ranks['total'], mutation.query_n, + mutation.target_n, mutation.bp_ww, mutation.bp_mm, *values]) + return header, rows + + +def write_csv(mutations, handle, delimiter='\t'): + """Write ranked mutations with deterministic UTF-8/LF-compatible CSV.""" + writer = csv.writer(handle, delimiter=delimiter, lineterminator='\n') + header, rows = table(mutations) + writer.writerow(header) + writer.writerows(rows) diff --git a/python/tests/test_copomus.py b/python/tests/test_copomus.py index 11ffcfc3..0e4ff5d8 100644 --- a/python/tests/test_copomus.py +++ b/python/tests/test_copomus.py @@ -1,7 +1,9 @@ """CopomuS regressions for coordinates, mutation selection, profiles and ranking.""" import csv +import asyncio import io +import json from pathlib import Path import sys import tempfile @@ -17,6 +19,7 @@ from copomus.measures import MinDeltaEnergy, MFECover, Measure from copomus.mutation import Mutation, parse_mutation_encoding from copomus.mutation_generators import gen_specific +from copomus.browser import run as run_browser class CopomuSTests(unittest.TestCase): @@ -151,6 +154,42 @@ def test_cli_explicit_mutation_zero_energy_csv(self): self.assertEqual(row['minDeltaE_min'], '0') self.assertNotIn('\r', stdout.getvalue()) + def test_browser_json_runner_reuses_scientific_workflow(self): + calls = [] + energies = iter((-5, -1, -1, -4)) + + async def predict(payload): + call = json.loads(payload) + calls.append(call) + energy = next(energies) + columns = call['columns'] + values = { + 'id1': 'target', 'start1': 1, 'end1': 2, + 'id2': 'query', 'start2': 1, 'end2': 2, + 'subseqDP': 'GC&GC', 'hybridDP': '()&()', 'E': energy, + } + return json.dumps({'csv': ';'.join(columns) + '\n' + + ';'.join(str(values[column]) for column in columns) + '\n', + 'files': {}}) + + progress = [] + request = { + 'query': 'GC', 'target': 'GC', 'qIdxPos0': 1, 'tIdxPos0': 1, + 'copomus': { + 'measures': ['E'], 'candidateSelection': 'mfe', + 'candidateFilters': [], 'generator': 'flip', + 'mutationEncoding': 'G1C&C2G', 'alpha': 1, 'beta': 1, + }, + } + result = json.loads(asyncio.run(run_browser(json.dumps(request), predict, progress.append))) + self.assertEqual(len(calls), 4) + self.assertTrue(all(call['query'] and call['target'] for call in calls)) + self.assertEqual(result['candidates'][0]['values']['mutation'], 'G1C&C2G') + self.assertEqual(result['candidates'][0]['values']['E_is_valid'], 1) + self.assertEqual(result['candidates'][0]['interactions']['mm']['E'], -4) + self.assertTrue(result['tsv'].startswith('mutation\trank\tqIndex\ttIndex')) + self.assertIn('Evaluating mutation 1 of 1', progress[-1]) + if __name__ == '__main__': unittest.main() diff --git a/scripts/wasm/build.sh b/scripts/wasm/build.sh index 10499d68..14268dbe 100644 --- a/scripts/wasm/build.sh +++ b/scripts/wasm/build.sh @@ -12,7 +12,7 @@ jobs=${JOBS:-2} for tool in emcc em++ emar emranlib emconfigure emmake curl pkg-config node python3; do command -v "$tool" >/dev/null || { echo "Missing tool: $tool" >&2; exit 1; } done -mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" +mkdir -p "$build/downloads" "$build/sources" "$prefix" "$build/site/wasm" "$build/site/vendor/pyodide" fetch() { bash "$repo/scripts/wasm/download.sh" "$1" "$build/downloads/$2" "$3" } @@ -20,6 +20,19 @@ fetch https://zlib.net/fossils/zlib-1.3.1.tar.gz zlib-1.3.1.tar.gz \ 9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 fetch https://archives.boost.io/release/1.87.0/source/boost_1_87_0.tar.bz2 boost_1_87_0.tar.bz2 \ af57be25cb4c4f4b413ed692fe378affb4352ea50fbe294a11ef548f4d527d89 +pyodide_version=314.0.7 +fetch "https://registry.npmjs.org/pyodide/-/pyodide-$pyodide_version.tgz" "pyodide-$pyodide_version.tgz" \ + 66b7949e5a0cba2250b586d7e368bf12fb482800748d182c4618254b685ffaa0 +fetch "https://raw.githubusercontent.com/pyodide/pyodide/$pyodide_version/LICENSE" pyodide-LICENSE.txt \ + 1f256ecad192880510e84ad60474eab7589218784b9a50bc7ceee34c2b91f1d5 +pyodide_source="$build/sources/pyodide-$pyodide_version" +mkdir -p "$pyodide_source" +if [[ ! -s "$pyodide_source/pyodide.mjs" ]]; then + tar -xzf "$build/downloads/pyodide-$pyodide_version.tgz" -C "$pyodide_source" --strip-components=1 +fi +for file in pyodide.mjs pyodide.asm.mjs pyodide.asm.wasm pyodide-lock.json python_stdlib.zip; do + cp "$pyodide_source/$file" "$build/site/vendor/pyodide/$file" +done # CI supplies the newest stable 2.x manifest; local builds use the checked-in pin. read -r vienna_version vienna_url vienna_sha256 < <( python3 "$repo/scripts/wasm/vienna-release.py" --fields \ @@ -109,5 +122,5 @@ printf '{"type":"module"}\n' > "$build/site/wasm/package.json" node "$repo/scripts/wasm/write-build-info.mjs" "$repo" "$build/site" \ "$(emcc -dumpversion)" "$(git -C "$repo" rev-parse HEAD)" \ "$(git -C "$repo" status --porcelain --untracked-files=no)" \ - "$vienna_version" "$vienna_url" "$vienna_sha256" + "$vienna_version" "$vienna_url" "$vienna_sha256" "$pyodide_version" echo "WASM assets: $build/site/wasm" diff --git a/scripts/wasm/stage.sh b/scripts/wasm/stage.sh index 62b6cb3c..da34865a 100644 --- a/scripts/wasm/stage.sh +++ b/scripts/wasm/stage.sh @@ -7,20 +7,22 @@ site="$build/site" test -s "$site/wasm/intarna.js" test -s "$site/wasm/intarna.wasm" # Select notices from the archive used by these assets, including dlib upgrades. -read -r vienna_version vienna_sha256 dlib_directory < <(python3 - "$site" "$build" <<'PYSOURCE' +read -r vienna_version vienna_sha256 dlib_directory pyodide_version < <(python3 - "$site" "$build" <<'PYSOURCE' import json, re, sys from pathlib import Path site, build = map(Path, sys.argv[1:]) metadata = json.loads((site / "build-info.json").read_text()) archive = metadata["viennarnaArchive"] -version, digest = archive["version"], archive["sha256"] +version, digest, pyodide = archive["version"], archive["sha256"], metadata["pyodide"] if version != metadata["viennarna"] or not re.fullmatch(r"2\.\d+\.\d+", version) or not re.fullmatch(r"[0-9a-f]{64}", digest): raise SystemExit("Invalid compiled ViennaRNA archive identity") +if not re.fullmatch(r"\d+\.\d+\.\d+", pyodide): + raise SystemExit("Invalid staged Pyodide version") source = build / "sources" / f"vienna-{version}-{digest}" / f"ViennaRNA-{version}" dlib = list((source / "src").glob("dlib-*/dlib/LICENSE.txt")) if len(dlib) != 1 or not re.fullmatch(r"dlib-[0-9.]+", dlib[0].parent.parent.name): raise SystemExit("Expected one bundled dlib license in the compiled ViennaRNA source") -print(version, digest, dlib[0].parent.parent.name) +print(version, digest, dlib[0].parent.parent.name, pyodide) PYSOURCE ) vienna_source="$build/sources/vienna-$vienna_version-$vienna_sha256/ViennaRNA-$vienna_version" @@ -28,13 +30,20 @@ mkdir -p "$site/licenses" cp -R "$repo/gui/assets" "$repo/gui/examples" "$repo/gui/vendor" "$site/" test "$(sha256sum "$repo/gui/vendor/plotly-cartesian.min.js" | cut -d' ' -f1)" = "aaf12ff1d4671f7e93f6df4291651ab4ca6b76c0ea5f1576ec1030e3c2819a20" test "$(sha256sum "$repo/gui/vendor/plotly.css" | cut -d' ' -f1)" = "3bcf2679b557067d8a3492b74092e946544d1cb8b2a72f278bcba95df59afd33" -for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js sessions.js visualization.js output-visualization.js parameter-file.js uploads.js; do +for file in index.html styles.css app.js mark.svg protocol.js runner.js results.js options.js columns.js archive.js sessions.js visualization.js output-visualization.js parameter-file.js uploads.js copomus.js copomus-options.js; do cp "$repo/gui/$file" "$site/$file" done cp "$repo/gui/intarna-worker.js" "$site/intarna-worker.js" +cp "$repo/gui/copomus-worker.js" "$site/copomus-worker.js" +mkdir -p "$site/python/copomus" +cp "$repo/python/intarna_tools.py" "$site/python/intarna_tools.py" +for file in __init__.py browser.py workflow.py IntaRNA.py indexing.py candidate_filters.py measures.py mutation.py mutation_generators.py; do + cp "$repo/python/copomus/$file" "$site/python/copomus/$file" +done touch "$site/.nojekyll" cp "$repo/src/bin/nlohmann/LICENSE.MIT" "$site/licenses/nlohmann-json.txt" cp "$repo/gui/vendor/plotly-LICENSE.txt" "$site/licenses/Plotly.txt" +cp "$build/downloads/pyodide-LICENSE.txt" "$site/licenses/Pyodide.txt" cp "$repo/LICENSE" "$site/licenses/IntaRNA.txt" cp "$repo/src/easylogging++.LICENCE.txt" "$site/licenses/easylogging.txt" cp "$repo/src/mdspan/LICENSE" "$site/licenses/mdspan.txt" @@ -53,11 +62,12 @@ cat > "$site/licenses/index.html" <IntaRNA licenses

IntaRNA and third-party notices

IntaRNA: Bioinformatics Group of Prof. Backofen, University of Freiburg.
ViennaRNA: authors and Institute for Theoretical Chemistry, University of Vienna.

-

This distribution uses ViennaRNA $vienna_version, Boost 1.87.0, zlib 1.3.1, bundled dlib ${dlib_directory#dlib-}, Plotly.js 4.1.2, Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.

+

This distribution uses ViennaRNA $vienna_version, Boost 1.87.0, zlib 1.3.1, bundled dlib ${dlib_directory#dlib-}, Plotly.js 4.1.2, Pyodide $pyodide_version, Kokkos mdspan, Easylogging++ and the Emscripten 4.0.23 runtime.

  • IntaRNA
  • ViennaRNA
  • nlohmann/json
  • Plotly.js
  • +
  • Pyodide
  • Boost
  • zlib
  • dlib
  • Naview
  • Kokkos mdspan
  • Easylogging++
  • @@ -78,5 +88,8 @@ if not (site / "build-info.txt").is_file(): for name, digest in metadata["sha256"].items(): if hashlib.sha256((site / "wasm" / name).read_bytes()).hexdigest() != digest: raise SystemExit(f"Mismatched engine: {name}") +for name, digest in metadata["pyodideSha256"].items(): + if hashlib.sha256((site / "vendor" / "pyodide" / name).read_bytes()).hexdigest() != digest: + raise SystemExit(f"Mismatched Pyodide runtime: {name}") PYINFO echo "Static site: $site" diff --git a/scripts/wasm/test-browser.py b/scripts/wasm/test-browser.py index 8457c058..aa3f3bc6 100644 --- a/scripts/wasm/test-browser.py +++ b/scripts/wasm/test-browser.py @@ -33,7 +33,7 @@ def viewer(route): status = page.locator('#result-status') sessions = page.locator('.session') active = page.locator('.session:has([data-role=panel]:not([hidden]))') - rows = active.locator('tbody tr') + rows = active.locator('[data-role=results-view] tbody tr') def open_group(key): page.locator('#tab-' + key).click() @@ -42,6 +42,12 @@ def open_group(key): assert page.locator('.parameter-group:visible').count() == 1 def load_reference(task='prediction'): + if task == 'evaluation': + page.get_by_role('radio', name='Evaluation').check() + elif task == 'mutation': + page.get_by_role('radio', name='Mutation Design').check() + else: + page.get_by_role('radio', name='Prediction').check() page.locator('#examples-menu summary').click() page.locator(f'#example-options [data-example-task="{task}"]').click() expect(page.locator('#query')).not_to_have_value('') @@ -54,10 +60,14 @@ def load_reference(task='prediction'): page.locator('#reset').click() if task == 'evaluation': page.get_by_role('radio', name='Evaluation').check() + elif task == 'mutation': + page.get_by_role('radio', name='Mutation Design').check() page.locator('#query').fill('>query\nGGGAGGAGGG\n') page.locator('#target').fill('>target\nCCCUCCUCCC\n') if task == 'evaluation': page.locator('#rri').fill('1||||||||||&1||||||||||') + elif task == 'mutation': + page.locator('#copomus-mutation-encoding').fill('G1C&C10G') expect(page.locator('#query')).to_have_value('>query\nGGGAGGAGGG\n') def download_archive(session=None): @@ -106,11 +116,18 @@ def predict(): page.locator('#examples-menu summary').click() prediction_example = page.locator('#example-options [data-example-task="prediction"]') evaluation_example = page.locator('#example-options [data-example-task="evaluation"]') + mutation_example = page.locator('#example-options [data-example-task="mutation"]') expect(prediction_example).to_be_visible() expect(evaluation_example).not_to_be_visible() + expect(mutation_example).not_to_be_visible() page.get_by_role('radio', name='Evaluation').check() expect(evaluation_example).to_be_visible() expect(prediction_example).not_to_be_visible() + expect(mutation_example).not_to_be_visible() + page.get_by_role('radio', name='Mutation Design').check() + expect(mutation_example).to_be_visible() + expect(prediction_example).not_to_be_visible() + expect(evaluation_example).not_to_be_visible() page.get_by_role('radio', name='Prediction').check() page.locator('#examples-menu summary').click() @@ -311,7 +328,7 @@ def predict(): page.locator('#outCsvSort').select_option('E') predict() expect(rows).to_have_count(10) - assert active.locator('.table-scroll').evaluate('''node => { + assert active.locator('[data-role=results-view] .table-scroll').evaluate('''node => { const body = node.querySelector('tbody'); const copies = [...body.children].map(row => row.cloneNode(true)); body.append(...copies); @@ -331,9 +348,9 @@ def predict(): active.locator('[data-role=search]').fill('no match') expect(active.locator('[data-role=no-matches]')).to_be_visible() active.locator('[data-role=search]').fill('') - active.locator('.column-picker summary').click() + active.locator('[data-role=results-view] .column-picker summary').click() active.locator('[data-role=visible-columns] input[value="start1"]').uncheck() - assert 'Target start' not in active.locator('thead').inner_text() + assert 'Target start' not in active.locator('[data-role=results-view] thead').inner_text() active.locator('[data-role=visible-columns] input[value="start1"]').check() with page.expect_download() as downloaded: active.locator('[data-role=download]').click() @@ -460,7 +477,7 @@ def predict(): predict() expect(status).to_contain_text('Evaluation complete') expect(rows).to_have_count(2) - expect(active.locator('thead')).to_contain_text('ED1') + expect(active.locator('[data-role=results-view] thead')).to_contain_text('ED1') expect(active.locator('[data-role=file-list] summary')).to_contain_text('intarna-detailed.txt') active.locator('[data-role=file-list] summary').click() expect(active.locator('[data-role=file-list] pre')).to_contain_text('-21.4') @@ -521,7 +538,7 @@ def predict(): assert download_archive()['intarna-interactions.csv'].count('\n') == 1 fill('query', '>\nGGGAGGAGGG') predict() - assert active.locator('tbody img').count() == 0 + assert active.locator('[data-role=results-view] tbody img').count() == 0 assert ' pathToFileURL(resolve(site, 'wasm/intarna.wasm')).href, print: (line) => lines.push(line), printErr: (line) => lines.push(line) }); @@ -15,11 +16,12 @@ if (match[2] !== viennaVersion) throw new Error(`Compiled ViennaRNA ${match[2]} const metadata = { source: 'https://github.com/BackofenLab/IntaRNA', commit, dirty: !!changes, - intarna: match[1], viennarna: match[2], boost: match[3], emscripten, zlib: '1.3.1', + intarna: match[1], viennarna: match[2], boost: match[3], emscripten, zlib: '1.3.1', pyodide: pyodideVersion, viennarnaArchive: { version: viennaVersion, url: viennaUrl, sha256: viennaSha256 }, threading: 'single worker per task', sha256: Object.fromEntries(['intarna.js', 'intarna.wasm'].map((name) => [name, createHash('sha256').update(readFileSync(resolve(site, 'wasm', name))).digest('hex')])), + pyodideSha256: Object.fromEntries(['pyodide.mjs', 'pyodide.asm.mjs', 'pyodide.asm.wasm', 'pyodide-lock.json', 'python_stdlib.zip'].map((name) => [name, createHash('sha256').update(readFileSync(resolve(site, 'vendor/pyodide', name))).digest('hex')])), }; writeFileSync(resolve(site, 'build-info.json'), JSON.stringify(metadata, null, 2) + '\n'); -writeFileSync(resolve(site, 'build-info.txt'), `${version}\nIntaRNA repository: ${metadata.source}\nIntaRNA revision: ${metadata.commit}${metadata.dirty ? ' (modified sources)' : ''}\nEmscripten: ${emscripten}\nzlib: ${metadata.zlib}\nViennaRNA archive: ${viennaUrl}\nViennaRNA archive SHA-256: ${viennaSha256}\n${Object.entries(metadata.sha256).map(([name, hash]) => `SHA-256 ${name}: ${hash}`).join('\n')}\n`); +writeFileSync(resolve(site, 'build-info.txt'), `${version}\nIntaRNA repository: ${metadata.source}\nIntaRNA revision: ${metadata.commit}${metadata.dirty ? ' (modified sources)' : ''}\nEmscripten: ${emscripten}\nPyodide: ${pyodideVersion}\nzlib: ${metadata.zlib}\nViennaRNA archive: ${viennaUrl}\nViennaRNA archive SHA-256: ${viennaSha256}\n${Object.entries(metadata.sha256).map(([name, hash]) => `SHA-256 ${name}: ${hash}`).join('\n')}\n${Object.entries(metadata.pyodideSha256).map(([name, hash]) => `SHA-256 Pyodide ${name}: ${hash}`).join('\n')}\n`); console.log(`Recorded compiled versions: IntaRNA ${match[1]}, ViennaRNA ${match[2]}, Boost ${match[3]}`);