diff --git a/collab-notebook-reproducibility-guard/README.md b/collab-notebook-reproducibility-guard/README.md new file mode 100644 index 00000000..5ed758d4 --- /dev/null +++ b/collab-notebook-reproducibility-guard/README.md @@ -0,0 +1,32 @@ +# Collaborative Notebook Reproducibility Guard + +This module adds a dependency-free pre-acceptance guard for embedded Jupyter-style outputs in the real-time collaborative research editor. + +It checks whether notebook outputs are reproducible enough to be accepted into a shared manuscript section. The guard reviews execution order, kernel/runtime fingerprints, dependency lock hashes, random seed capture, input artifact fingerprints, stale output timestamps, unsafe rich HTML, private local paths, and section-version drift. + +## Commands + +```bash +npm test +npm run demo +npm run video +npm run check +``` + +## Outputs + +The demo writes deterministic artifacts to `reports/`: + +- `unsafe-notebook-packet.json` +- `warning-notebook-packet.json` +- `clean-notebook-packet.json` +- `notebook-reproducibility-report.md` +- `maintainer-verification-packet.md` +- `summary.svg` +- `demo.mp4` + +## Status Lanes + +- `hold_notebook_outputs`: block shared manuscript insertion and require a clean rerun. +- `stage_for_reproducibility_review`: allow review only after metadata is completed. +- `accept_notebook_outputs`: output packet is ready for collaborative acceptance. diff --git a/collab-notebook-reproducibility-guard/acceptance-notes.md b/collab-notebook-reproducibility-guard/acceptance-notes.md new file mode 100644 index 00000000..5a0e08a1 --- /dev/null +++ b/collab-notebook-reproducibility-guard/acceptance-notes.md @@ -0,0 +1,13 @@ +# Acceptance Notes + +Validation performed locally: + +- `node test.js` +- `npm test` +- `npm run demo` +- `npm run video` +- `npm run check` (syntax, tests, report generation, and demo video generation) +- `ffprobe` on `reports/demo.mp4` +- `git diff --check` + +The data is synthetic. No external services, credentials, private manuscripts, live collaborator data, or payment details are used. diff --git a/collab-notebook-reproducibility-guard/demo.js b/collab-notebook-reproducibility-guard/demo.js new file mode 100644 index 00000000..91c89872 --- /dev/null +++ b/collab-notebook-reproducibility-guard/demo.js @@ -0,0 +1,154 @@ +const fs = require('fs'); +const path = require('path'); + +const { assessNotebookOutputPacket } = require('./index'); +const { cleanPacket, unsafePacket, warningPacket } = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const unsafeReview = assessNotebookOutputPacket(unsafePacket); +const warningReview = assessNotebookOutputPacket(warningPacket); +const cleanReview = assessNotebookOutputPacket(cleanPacket); + +writeJson('unsafe-notebook-packet.json', unsafeReview); +writeJson('warning-notebook-packet.json', warningReview); +writeJson('clean-notebook-packet.json', cleanReview); +writeMarkdownReport(unsafeReview, warningReview, cleanReview); +writeMaintainerVerificationPacket(unsafeReview, warningReview, cleanReview); +writeSummarySvg(unsafeReview, warningReview, cleanReview); + +console.log('Generated notebook reproducibility artifacts:'); +console.log(`- ${path.join(reportsDir, 'unsafe-notebook-packet.json')}`); +console.log(`- ${path.join(reportsDir, 'warning-notebook-packet.json')}`); +console.log(`- ${path.join(reportsDir, 'clean-notebook-packet.json')}`); +console.log(`- ${path.join(reportsDir, 'notebook-reproducibility-report.md')}`); +console.log(`- ${path.join(reportsDir, 'maintainer-verification-packet.md')}`); +console.log(`- ${path.join(reportsDir, 'summary.svg')}`); + +function writeJson(filename, value) { + fs.writeFileSync(path.join(reportsDir, filename), `${JSON.stringify(value, null, 2)}\n`); +} + +function writeMarkdownReport(unsafeReview, warningReview, cleanReview) { + const lines = [ + '# Collaborative Notebook Reproducibility Guard', + '', + 'Synthetic demo packet review for issue #12.', + '', + '| Packet | Status | Cells | Blockers | Warnings | Action |', + '| --- | --- | ---: | ---: | ---: | --- |', + row('Unsafe packet', unsafeReview), + row('Warning packet', warningReview), + row('Clean packet', cleanReview), + '', + '## Reviewer Notes', + '', + '- Unsafe packets are held before shared manuscript insertion.', + '- Rich HTML outputs are sanitized and local/private paths are redacted.', + '- Clean continuous reruns with runtime, seed, dependency lock, and input fingerprints are accepted.' + ]; + + fs.writeFileSync(path.join(reportsDir, 'notebook-reproducibility-report.md'), `${lines.join('\n')}\n`); +} + +function row(label, packet) { + const summary = packet.reproducibilitySummary; + return `| ${label} | ${packet.status} | ${summary.cells} | ${summary.blockers} | ${summary.warnings} | ${primaryAction(packet)} |`; +} + +function writeMaintainerVerificationPacket(unsafeReview, warningReview, cleanReview) { + const blockerCodes = unsafeReview.findings + .filter((finding) => finding.severity === 'blocker') + .map((finding) => finding.code); + const sanitizedLoadCell = unsafeReview.cells.find((cell) => cell.id === 'cell-load-data'); + const sanitizedModelCell = unsafeReview.cells.find((cell) => cell.id === 'cell-fit-model'); + const lines = [ + '# Maintainer Verification Packet', + '', + 'This packet gives reviewers a compact checklist for issue #12 acceptance.', + '', + '## Acceptance Evidence', + '', + `- Unsafe packet lane: ${unsafeReview.status}`, + `- Warning packet lane: ${warningReview.status}`, + `- Clean packet lane: ${cleanReview.status}`, + `- Unsafe blocker count: ${unsafeReview.reproducibilitySummary.blockers}`, + `- Warning count: ${warningReview.reproducibilitySummary.warnings}`, + `- Clean accepted cells: ${cleanReview.reproducibilitySummary.cells}`, + '', + '## Guard Coverage', + '', + '| Requirement | Evidence |', + '| --- | --- |', + `| Execution-order continuity | ${blockerCodes.includes('EXECUTION_ORDER_GAP') ? 'covered by unsafe packet' : 'missing'} |`, + `| Dependency/version integrity | ${blockerCodes.includes('DEPENDENCY_LOCK_MISMATCH') ? 'covered by unsafe packet' : 'missing'} |`, + `| Random-seed capture | ${blockerCodes.includes('MISSING_RANDOM_SEED') ? 'covered by unsafe packet' : 'missing'} |`, + `| Input artifact fingerprints | ${blockerCodes.includes('MISSING_INPUT_FINGERPRINT') ? 'covered by unsafe packet' : 'missing'} |`, + `| Stale output detection | ${blockerCodes.includes('STALE_NOTEBOOK_OUTPUT') ? 'covered by unsafe packet' : 'missing'} |`, + `| Section-version drift | ${blockerCodes.includes('SECTION_VERSION_MISMATCH') ? 'covered by unsafe packet' : 'missing'} |`, + `| Rich HTML sanitization | ${sanitizedModelCell?.outputs?.[0]?.trusted === false ? 'sanitized and marked untrusted' : 'missing'} |`, + `| Private path redaction | ${/\[redacted-local-path]/.test(sanitizedLoadCell?.outputs?.[0]?.content || '') ? 'redacted in output packet' : 'missing'} |`, + '', + '## Deterministic Digests', + '', + `- Unsafe packet digest: ${unsafeReview.auditDigest}`, + `- Warning packet digest: ${warningReview.auditDigest}`, + `- Clean packet digest: ${cleanReview.auditDigest}`, + '', + '## Local Command', + '', + '```bash', + 'npm run check', + '```' + ]; + + fs.writeFileSync(path.join(reportsDir, 'maintainer-verification-packet.md'), `${lines.join('\n')}\n`); +} + +function primaryAction(packet) { + return ( + packet.actions.find((action) => action.startsWith('hold_notebook_outputs')) || + packet.actions.find((action) => action.startsWith('accept_notebook_outputs')) || + packet.actions[0] + ); +} + +function writeSummarySvg(unsafeReview, warningReview, cleanReview) { + const cards = [ + { label: 'Unsafe', packet: unsafeReview, color: '#b91c1c', x: 28 }, + { label: 'Warning', packet: warningReview, color: '#b45309', x: 318 }, + { label: 'Clean', packet: cleanReview, color: '#047857', x: 608 } + ]; + + const cardSvg = cards.map(({ label, packet, color, x }) => { + const summary = packet.reproducibilitySummary; + return [ + ``, + `${label}`, + `${escapeXml(packet.status)}`, + `Cells: ${summary.cells}`, + `Blockers: ${summary.blockers}`, + `Warnings: ${summary.warnings}` + ].join('\n'); + }).join('\n'); + + const svg = [ + '', + '', + 'Notebook Output Reproducibility Guard', + 'Pre-acceptance validation for collaborative Jupyter-style output packets', + cardSvg, + '' + ].join('\n'); + + fs.writeFileSync(path.join(reportsDir, 'summary.svg'), `${svg}\n`); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/collab-notebook-reproducibility-guard/index.js b/collab-notebook-reproducibility-guard/index.js new file mode 100644 index 00000000..d6079e99 --- /dev/null +++ b/collab-notebook-reproducibility-guard/index.js @@ -0,0 +1,350 @@ +const crypto = require('crypto'); + +function assessNotebookOutputPacket(packet) { + const cells = packet.cells || []; + const orderFindings = assessExecutionOrder(cells); + const assessedCells = cells.map((cell) => assessCell(cell, packet)); + const findings = [ + ...orderFindings, + ...assessedCells.flatMap((item) => item.findings) + ].sort(compareFindings); + + const status = chooseStatus(findings); + const reviewPacket = { + packetId: packet.packetId, + workspaceId: packet.workspaceId, + manuscriptId: packet.manuscriptId, + status, + acceptanceLanes: chooseAcceptanceLanes(status), + assessedAt: packet.receivedAt, + cells: assessedCells.map((item) => item.sanitizedCell), + findings, + actions: buildActions(packet, findings), + reproducibilitySummary: buildSummary(cells, findings) + }; + + reviewPacket.auditDigest = digestPacket(reviewPacket); + return reviewPacket; +} + +function assessCell(cell, packet) { + const findings = []; + const sanitizedCell = sanitizeCellBase(cell); + + if (!cell.kernel || !cell.kernel.name || !cell.kernel.version) { + findings.push(finding({ + code: 'MISSING_KERNEL_VERSION', + severity: 'blocker', + cellId: cell.id, + message: 'Notebook output is missing the kernel name or version used to produce it.' + })); + } else if (!cell.kernel.runtimeDigest) { + findings.push(finding({ + code: 'MISSING_RUNTIME_DIGEST', + severity: 'warning', + cellId: cell.id, + message: 'Kernel version is present but the runtime digest was not captured.' + })); + } + + const expectedLock = expectedDependencyLock(packet, cell); + if (expectedLock && cell.dependencyLock?.digest !== expectedLock.digest) { + findings.push(finding({ + code: 'DEPENDENCY_LOCK_MISMATCH', + severity: 'blocker', + cellId: cell.id, + message: `Notebook output was created with dependency lock ${cell.dependencyLock?.digest || 'missing'}, expected ${expectedLock.digest}.` + })); + } + + if (cell.stochastic && !cell.randomSeed) { + findings.push(finding({ + code: 'MISSING_RANDOM_SEED', + severity: 'blocker', + cellId: cell.id, + message: 'Stochastic notebook cell output cannot be accepted without a captured random seed.' + })); + } + + const missingInputs = (cell.inputs || []).filter((input) => !input.digest); + if (missingInputs.length) { + findings.push(finding({ + code: 'MISSING_INPUT_FINGERPRINT', + severity: 'blocker', + cellId: cell.id, + message: `${missingInputs.length} notebook input artifact(s) lack a content fingerprint.` + })); + } + + if (isStaleOutput(cell, packet, expectedLock)) { + findings.push(finding({ + code: 'STALE_NOTEBOOK_OUTPUT', + severity: 'blocker', + cellId: cell.id, + message: 'Notebook output predates the current data, dependency, or manuscript section revision.' + })); + } + + if (hasUntrustedRichHtml(cell.outputs)) { + findings.push(finding({ + code: 'UNTRUSTED_RICH_HTML_OUTPUT', + severity: 'blocker', + cellId: cell.id, + message: 'Notebook rich output contains untrusted HTML or scriptable attributes.' + })); + sanitizedCell.outputs = sanitizeOutputs(cell.outputs || []); + } + + if (containsPrivatePathInOutputs(cell.outputs)) { + findings.push(finding({ + code: 'PRIVATE_OUTPUT_PATH', + severity: 'blocker', + cellId: cell.id, + message: 'Notebook output references a local or private filesystem path.' + })); + sanitizedCell.outputs = sanitizeOutputs(sanitizedCell.outputs || cell.outputs || []); + } + + const currentSectionVersion = packet.currentSectionVersions?.[cell.sectionId]; + if (currentSectionVersion && cell.sectionVersion !== currentSectionVersion) { + findings.push(finding({ + code: 'SECTION_VERSION_MISMATCH', + severity: 'blocker', + cellId: cell.id, + message: `Output targets section version ${cell.sectionVersion || 'missing'}, expected ${currentSectionVersion}.` + })); + } + + sanitizedCell.reviewState = findings.length ? 'reproducibility_review_required' : 'ready_for_collaborative_acceptance'; + return { sanitizedCell, findings }; +} + +function assessExecutionOrder(cells) { + const findings = []; + const byNotebook = new Map(); + + for (const cell of cells) { + const key = cell.notebookId || 'unknown-notebook'; + if (!byNotebook.has(key)) byNotebook.set(key, []); + byNotebook.get(key).push(cell); + } + + for (const [notebookId, notebookCells] of byNotebook.entries()) { + const counts = new Map(); + for (const cell of notebookCells) { + if (!Number.isInteger(cell.executionCount) || cell.executionCount <= 0) { + findings.push(finding({ + code: 'INVALID_EXECUTION_COUNT', + severity: 'blocker', + cellId: cell.id, + message: `Notebook ${notebookId} cell has no positive execution counter.` + })); + continue; + } + counts.set(cell.executionCount, (counts.get(cell.executionCount) || 0) + 1); + } + + for (const cell of notebookCells) { + if (counts.get(cell.executionCount) > 1) { + findings.push(finding({ + code: 'DUPLICATE_EXECUTION_COUNT', + severity: 'blocker', + cellId: cell.id, + message: `Execution count ${cell.executionCount} appears more than once in ${notebookId}.` + })); + } + } + + const ordered = notebookCells + .filter((cell) => Number.isInteger(cell.executionCount)) + .sort((a, b) => a.executionCount - b.executionCount); + for (let index = 1; index < ordered.length; index += 1) { + if (ordered[index].executionCount !== ordered[index - 1].executionCount + 1) { + findings.push(finding({ + code: 'EXECUTION_ORDER_GAP', + severity: 'blocker', + cellId: ordered[index].id, + message: `Notebook ${notebookId} output sequence is not a clean rerun from a continuous kernel.` + })); + break; + } + } + } + + return findings; +} + +function sanitizeCellBase(cell) { + const sanitized = clone(cell); + sanitized.outputs = sanitizeOutputs(cell.outputs || []); + return sanitized; +} + +function sanitizeOutputs(outputs) { + return outputs.map((output) => { + const sanitized = { ...output }; + if (typeof sanitized.content === 'string') { + sanitized.content = stripUnsafeHtml(redactPrivatePaths(sanitized.content)); + } + if (sanitized.mime === 'text/html') { + sanitized.trusted = false; + } + return sanitized; + }); +} + +function hasUntrustedRichHtml(outputs = []) { + return outputs.some((output) => ( + output.mime === 'text/html' && + (output.trusted !== true || / containsPrivatePath(output.content || '')); +} + +function containsPrivatePath(value) { + return /(?:file:\/\/|\/Users\/[^ \n<]+|\/home\/[^ \n<]+|private-lab|patient-export|restricted-dataset)/i.test(value); +} + +function redactPrivatePaths(value) { + return value + .replace(/file:\/\/[^ \n<]+/gi, '[redacted-local-path]') + .replace(/\/Users\/[^ \n<]+/g, '[redacted-local-path]') + .replace(/\/home\/[^ \n<]+/g, '[redacted-local-path]') + .replace(/private-lab\/[^\s<]+/gi, 'private-lab/[redacted]') + .replace(/restricted-dataset\/[^\s<]+/gi, 'restricted-dataset/[redacted]'); +} + +function stripUnsafeHtml(value) { + return value + .replace(/]*>[\s\S]*?<\/script>/gi, '[removed-script]') + .replace(/\son(?:error|click|load)\s*=\s*["'][^"']*["']/gi, '') + .replace(/javascript:/gi, 'blocked-javascript:'); +} + +function expectedDependencyLock(packet, cell) { + const locks = packet.acceptedDependencyLocks || {}; + return locks[cell.notebookId] || locks[cell.kernel?.name] || null; +} + +function isStaleOutput(cell, packet, expectedLock) { + const executedAt = Date.parse(cell.lastExecutedAt); + if (!executedAt) return true; + + const freshnessDates = [ + packet.currentDataRevisionAt, + packet.currentNotebookRevisionAt, + expectedLock?.updatedAt + ].map((value) => Date.parse(value)).filter(Boolean); + + return freshnessDates.some((freshAt) => executedAt < freshAt); +} + +function chooseStatus(findings) { + if (findings.some((item) => item.severity === 'blocker')) return 'hold_notebook_outputs'; + if (findings.length) return 'stage_for_reproducibility_review'; + return 'accept_notebook_outputs'; +} + +function chooseAcceptanceLanes(status) { + if (status === 'hold_notebook_outputs') { + return { + manuscriptInsertion: 'blocked', + reviewerPreview: 'sanitized', + auditRetention: 'reproducibility_hold' + }; + } + if (status === 'stage_for_reproducibility_review') { + return { + manuscriptInsertion: 'review_required', + reviewerPreview: 'watermarked', + auditRetention: 'standard' + }; + } + return { + manuscriptInsertion: 'allowed', + reviewerPreview: 'allowed', + auditRetention: 'standard' + }; +} + +function buildActions(packet, findings) { + if (!findings.length) return [`accept_notebook_outputs:${packet.packetId}`]; + + const actions = new Set(); + if (findings.some((item) => item.severity === 'blocker')) { + actions.add(`hold_notebook_outputs:${packet.packetId}`); + actions.add('rerun_notebook_from_clean_kernel'); + } + for (const item of findings) { + if (item.code === 'MISSING_RUNTIME_DIGEST') actions.add(`capture_runtime_digest:${item.cellId}`); + if (item.code === 'DEPENDENCY_LOCK_MISMATCH') actions.add(`refresh_dependency_lock:${item.cellId}`); + if (item.code === 'MISSING_RANDOM_SEED') actions.add(`record_random_seed:${item.cellId}`); + if (item.code === 'MISSING_INPUT_FINGERPRINT') actions.add(`attach_input_fingerprints:${item.cellId}`); + if (item.code === 'STALE_NOTEBOOK_OUTPUT') actions.add(`rerun_stale_cell:${item.cellId}`); + if (item.code === 'UNTRUSTED_RICH_HTML_OUTPUT') actions.add(`sanitize_rich_output:${item.cellId}`); + if (item.code === 'PRIVATE_OUTPUT_PATH') actions.add(`redact_private_output_paths:${item.cellId}`); + if (item.code === 'SECTION_VERSION_MISMATCH') actions.add(`rebase_output_to_current_section:${item.cellId}`); + if (item.code === 'EXECUTION_ORDER_GAP' || item.code === 'DUPLICATE_EXECUTION_COUNT') { + actions.add('reset_kernel_and_run_all_cells'); + } + } + return Array.from(actions).sort(); +} + +function buildSummary(cells, findings) { + const blockers = findings.filter((item) => item.severity === 'blocker').length; + const warnings = findings.filter((item) => item.severity === 'warning').length; + const notebooks = new Set(cells.map((cell) => cell.notebookId || 'unknown-notebook')); + const trustedOutputs = cells.flatMap((cell) => cell.outputs || []).filter((output) => output.trusted === true).length; + + return { + notebooks: notebooks.size, + cells: cells.length, + trustedOutputs, + blockers, + warnings + }; +} + +function finding({ code, severity, cellId, message }) { + return { code, severity, cellId, message }; +} + +function compareFindings(a, b) { + const severityRank = { blocker: 0, warning: 1, info: 2 }; + return ( + severityRank[a.severity] - severityRank[b.severity] || + String(a.cellId || '').localeCompare(String(b.cellId || '')) || + a.code.localeCompare(b.code) + ); +} + +function digestPacket(packet) { + return digestValue(stableStringify(packet)); +} + +function digestValue(value) { + return crypto.createHash('sha256').update(String(value)).digest('hex'); +} + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +module.exports = { + assessNotebookOutputPacket, + sanitizeOutputs, + redactPrivatePaths, + stripUnsafeHtml +}; diff --git a/collab-notebook-reproducibility-guard/make-demo-video.py b/collab-notebook-reproducibility-guard/make-demo-video.py new file mode 100644 index 00000000..19407f04 --- /dev/null +++ b/collab-notebook-reproducibility-guard/make-demo-video.py @@ -0,0 +1,116 @@ +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +OUT = ROOT / "reports" / "demo.mp4" +TMP = ROOT / "reports" / "video-tmp" + +TMP.mkdir(parents=True, exist_ok=True) + +slides = [ + { + "title": "Unsafe packet", + "subtitle": "Held before manuscript insertion", + "body": "Blocks stale outputs, duplicate execution counts, unsafe HTML, private paths", + "action": "Action: rerun notebook from a clean kernel", + "color": "0xb91c1c", + }, + { + "title": "Warning packet", + "subtitle": "Runtime digest review required", + "body": "Stages metadata-only gaps without blocking collaborators", + "action": "Action: capture runtime digest", + "color": "0xb45309", + }, + { + "title": "Clean packet", + "subtitle": "Accepted for collaborative use", + "body": "Requires lock hash, input fingerprints, seed, and continuous execution order", + "action": "Action: accept notebook outputs", + "color": "0x047857", + }, +] + +FONT_CANDIDATES = [ + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/Library/Fonts/Arial.ttf", + "/System/Library/Fonts/Helvetica.ttc", +] +FONT = next((font for font in FONT_CANDIDATES if Path(font).exists()), None) + + +def drawtext(text, x, y, size, color="white"): + escaped = text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'") + parts = [] + if FONT: + parts.append(f"fontfile='{FONT}'") + parts.extend([ + f"text='{escaped}'", + f"x={x}", + f"y={y}", + f"fontsize={size}", + f"fontcolor={color}", + ]) + return "drawtext=" + ":".join(parts) + + +segments = [] +for index, slide in enumerate(slides): + segment = TMP / f"segment_{index}.mp4" + filters = [ + "drawbox=x=48:y=48:w=864:h=444:color=white@0.12:t=fill", + drawtext("Notebook Output Reproducibility Guard", 76, 78, 28), + drawtext(slide["title"], 76, 150, 58), + drawtext(slide["subtitle"], 80, 230, 32), + drawtext(slide["body"], 80, 306, 22), + drawtext(slide["action"], 80, 360, 24), + ] + subprocess.run( + [ + "ffmpeg", + "-y", + "-f", + "lavfi", + "-i", + f"color=c={slide['color']}:s=960x540:d=3", + "-vf", + ",".join(filters), + "-pix_fmt", + "yuv420p", + str(segment), + ], + cwd=str(ROOT), + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + segments.append(segment) + +concat_file = TMP / "segments.txt" +concat_file.write_text("".join(f"file '{segment}'\n" for segment in segments), encoding="utf-8") + +subprocess.run( + [ + "ffmpeg", + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_file), + "-c", + "copy", + str(OUT), + ], + cwd=str(ROOT), + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, +) + +for file in TMP.glob("*"): + file.unlink() +TMP.rmdir() + +print(f"Wrote {OUT} ({OUT.stat().st_size} bytes)") diff --git a/collab-notebook-reproducibility-guard/package.json b/collab-notebook-reproducibility-guard/package.json new file mode 100644 index 00000000..fac878e2 --- /dev/null +++ b/collab-notebook-reproducibility-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "collab-notebook-reproducibility-guard", + "version": "1.0.0", + "private": true, + "description": "Dependency-free reproducibility guard for collaborative notebook outputs in research editor manuscripts.", + "scripts": { + "test": "node test.js", + "demo": "node demo.js", + "video": "python3 make-demo-video.py", + "syntax": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js", + "check": "npm run syntax && npm test && npm run demo && npm run video" + } +} diff --git a/collab-notebook-reproducibility-guard/reports/clean-notebook-packet.json b/collab-notebook-reproducibility-guard/reports/clean-notebook-packet.json new file mode 100644 index 00000000..463b3ede --- /dev/null +++ b/collab-notebook-reproducibility-guard/reports/clean-notebook-packet.json @@ -0,0 +1,94 @@ +{ + "packetId": "notebook-output-packet-clean", + "workspaceId": "workspace-neuro-42", + "manuscriptId": "ms-neuro-response", + "status": "accept_notebook_outputs", + "acceptanceLanes": { + "manuscriptInsertion": "allowed", + "reviewerPreview": "allowed", + "auditRetention": "standard" + }, + "assessedAt": "2026-05-28T10:35:00Z", + "cells": [ + { + "id": "cell-clean-load", + "notebookId": "nb-response-model", + "sectionId": "results", + "sectionVersion": "sec-results-v9", + "executionCount": 1, + "lastExecutedAt": "2026-05-28T10:05:00Z", + "kernel": { + "name": "python", + "version": "3.11.8", + "runtimeDigest": "sha256:runtime-current" + }, + "dependencyLock": { + "manager": "pip-tools", + "digest": "sha256:lockfile-current-77a" + }, + "stochastic": false, + "inputs": [ + { + "id": "cohort-export", + "digest": "sha256:cohort-stable" + } + ], + "outputs": [ + { + "mime": "text/plain", + "content": "Loaded 128 anonymized rows from cohort artifact sha256:cohort-stable.", + "trusted": true + } + ], + "reviewState": "ready_for_collaborative_acceptance" + }, + { + "id": "cell-clean-fit", + "notebookId": "nb-response-model", + "sectionId": "results", + "sectionVersion": "sec-results-v9", + "executionCount": 2, + "lastExecutedAt": "2026-05-28T10:08:00Z", + "kernel": { + "name": "python", + "version": "3.11.8", + "runtimeDigest": "sha256:runtime-current" + }, + "dependencyLock": { + "manager": "pip-tools", + "digest": "sha256:lockfile-current-77a" + }, + "stochastic": true, + "randomSeed": { + "value": 20260528, + "capturedAt": "2026-05-28T10:07:59Z" + }, + "inputs": [ + { + "id": "cohort-export", + "digest": "sha256:cohort-stable" + } + ], + "outputs": [ + { + "mime": "text/markdown", + "content": "Model AUC: 0.82. Bootstrap CI generated with seed 20260528.", + "trusted": true + } + ], + "reviewState": "ready_for_collaborative_acceptance" + } + ], + "findings": [], + "actions": [ + "accept_notebook_outputs:notebook-output-packet-clean" + ], + "reproducibilitySummary": { + "notebooks": 1, + "cells": 2, + "trustedOutputs": 2, + "blockers": 0, + "warnings": 0 + }, + "auditDigest": "2c600c8f6698badf782674dc103ce798936b00df1db2fab78b357c18fdcd88d6" +} diff --git a/collab-notebook-reproducibility-guard/reports/demo.mp4 b/collab-notebook-reproducibility-guard/reports/demo.mp4 new file mode 100644 index 00000000..ddd07fe3 Binary files /dev/null and b/collab-notebook-reproducibility-guard/reports/demo.mp4 differ diff --git a/collab-notebook-reproducibility-guard/reports/maintainer-verification-packet.md b/collab-notebook-reproducibility-guard/reports/maintainer-verification-packet.md new file mode 100644 index 00000000..8567faa5 --- /dev/null +++ b/collab-notebook-reproducibility-guard/reports/maintainer-verification-packet.md @@ -0,0 +1,37 @@ +# Maintainer Verification Packet + +This packet gives reviewers a compact checklist for issue #12 acceptance. + +## Acceptance Evidence + +- Unsafe packet lane: hold_notebook_outputs +- Warning packet lane: stage_for_reproducibility_review +- Clean packet lane: accept_notebook_outputs +- Unsafe blocker count: 10 +- Warning count: 1 +- Clean accepted cells: 2 + +## Guard Coverage + +| Requirement | Evidence | +| --- | --- | +| Execution-order continuity | covered by unsafe packet | +| Dependency/version integrity | covered by unsafe packet | +| Random-seed capture | covered by unsafe packet | +| Input artifact fingerprints | covered by unsafe packet | +| Stale output detection | covered by unsafe packet | +| Section-version drift | covered by unsafe packet | +| Rich HTML sanitization | sanitized and marked untrusted | +| Private path redaction | redacted in output packet | + +## Deterministic Digests + +- Unsafe packet digest: 93a4bc47b453aef1692c052c2ec1493e8b7d0457e2f983f30a16abb6c74fec50 +- Warning packet digest: d9019926c34ff26cfb97dc1caad437a788edd94336aad7efc3e48042846a9fe8 +- Clean packet digest: 2c600c8f6698badf782674dc103ce798936b00df1db2fab78b357c18fdcd88d6 + +## Local Command + +```bash +npm run check +``` diff --git a/collab-notebook-reproducibility-guard/reports/notebook-reproducibility-report.md b/collab-notebook-reproducibility-guard/reports/notebook-reproducibility-report.md new file mode 100644 index 00000000..b534a1ec --- /dev/null +++ b/collab-notebook-reproducibility-guard/reports/notebook-reproducibility-report.md @@ -0,0 +1,15 @@ +# Collaborative Notebook Reproducibility Guard + +Synthetic demo packet review for issue #12. + +| Packet | Status | Cells | Blockers | Warnings | Action | +| --- | --- | ---: | ---: | ---: | --- | +| Unsafe packet | hold_notebook_outputs | 3 | 10 | 1 | hold_notebook_outputs:notebook-output-packet-unsafe | +| Warning packet | stage_for_reproducibility_review | 1 | 0 | 1 | capture_runtime_digest:cell-methods-summary | +| Clean packet | accept_notebook_outputs | 2 | 0 | 0 | accept_notebook_outputs:notebook-output-packet-clean | + +## Reviewer Notes + +- Unsafe packets are held before shared manuscript insertion. +- Rich HTML outputs are sanitized and local/private paths are redacted. +- Clean continuous reruns with runtime, seed, dependency lock, and input fingerprints are accepted. diff --git a/collab-notebook-reproducibility-guard/reports/summary.svg b/collab-notebook-reproducibility-guard/reports/summary.svg new file mode 100644 index 00000000..cf797f16 --- /dev/null +++ b/collab-notebook-reproducibility-guard/reports/summary.svg @@ -0,0 +1,23 @@ + + +Notebook Output Reproducibility Guard +Pre-acceptance validation for collaborative Jupyter-style output packets + +Unsafe +hold_notebook_outputs +Cells: 3 +Blockers: 10 +Warnings: 1 + +Warning +stage_for_reproducibility_review +Cells: 1 +Blockers: 0 +Warnings: 1 + +Clean +accept_notebook_outputs +Cells: 2 +Blockers: 0 +Warnings: 0 + diff --git a/collab-notebook-reproducibility-guard/reports/unsafe-notebook-packet.json b/collab-notebook-reproducibility-guard/reports/unsafe-notebook-packet.json new file mode 100644 index 00000000..6a32b0ad --- /dev/null +++ b/collab-notebook-reproducibility-guard/reports/unsafe-notebook-packet.json @@ -0,0 +1,198 @@ +{ + "packetId": "notebook-output-packet-unsafe", + "workspaceId": "workspace-neuro-42", + "manuscriptId": "ms-neuro-response", + "status": "hold_notebook_outputs", + "acceptanceLanes": { + "manuscriptInsertion": "blocked", + "reviewerPreview": "sanitized", + "auditRetention": "reproducibility_hold" + }, + "assessedAt": "2026-05-28T10:15:00Z", + "cells": [ + { + "id": "cell-load-data", + "notebookId": "nb-response-model", + "sectionId": "results", + "sectionVersion": "sec-results-v8", + "executionCount": 12, + "lastExecutedAt": "2026-05-28T09:30:00Z", + "kernel": { + "name": "python", + "version": "3.11.8" + }, + "dependencyLock": { + "manager": "pip-tools", + "digest": "sha256:lockfile-old-12b" + }, + "stochastic": false, + "inputs": [ + { + "id": "cohort-export", + "path": "restricted-dataset/cohort.csv" + } + ], + "outputs": [ + { + "mime": "text/plain", + "content": "Loaded cohort from [redacted-local-path]", + "trusted": true + } + ], + "reviewState": "reproducibility_review_required" + }, + { + "id": "cell-fit-model", + "notebookId": "nb-response-model", + "sectionId": "results", + "sectionVersion": "sec-results-v9", + "executionCount": 14, + "lastExecutedAt": "2026-05-28T10:00:00Z", + "kernel": { + "name": "python", + "version": "3.11.8", + "runtimeDigest": "sha256:runtime-current" + }, + "dependencyLock": { + "manager": "pip-tools", + "digest": "sha256:lockfile-current-77a" + }, + "stochastic": true, + "inputs": [ + { + "id": "cohort-export", + "digest": "sha256:cohort-stable" + } + ], + "outputs": [ + { + "mime": "text/html", + "content": "[removed-script]", + "trusted": false + } + ], + "reviewState": "reproducibility_review_required" + }, + { + "id": "cell-render-figure", + "notebookId": "nb-response-model", + "sectionId": "results", + "sectionVersion": "sec-results-v9", + "executionCount": 14, + "lastExecutedAt": "2026-05-28T10:04:00Z", + "kernel": { + "name": "python", + "version": "3.11.8", + "runtimeDigest": "sha256:runtime-current" + }, + "dependencyLock": { + "manager": "pip-tools", + "digest": "sha256:lockfile-current-77a" + }, + "stochastic": false, + "inputs": [ + { + "id": "model-summary", + "digest": "sha256:model-summary" + } + ], + "outputs": [ + { + "mime": "image/svg+xml", + "content": "Response model", + "trusted": true + } + ], + "reviewState": "ready_for_collaborative_acceptance" + } + ], + "findings": [ + { + "code": "DUPLICATE_EXECUTION_COUNT", + "severity": "blocker", + "cellId": "cell-fit-model", + "message": "Execution count 14 appears more than once in nb-response-model." + }, + { + "code": "EXECUTION_ORDER_GAP", + "severity": "blocker", + "cellId": "cell-fit-model", + "message": "Notebook nb-response-model output sequence is not a clean rerun from a continuous kernel." + }, + { + "code": "MISSING_RANDOM_SEED", + "severity": "blocker", + "cellId": "cell-fit-model", + "message": "Stochastic notebook cell output cannot be accepted without a captured random seed." + }, + { + "code": "UNTRUSTED_RICH_HTML_OUTPUT", + "severity": "blocker", + "cellId": "cell-fit-model", + "message": "Notebook rich output contains untrusted HTML or scriptable attributes." + }, + { + "code": "DEPENDENCY_LOCK_MISMATCH", + "severity": "blocker", + "cellId": "cell-load-data", + "message": "Notebook output was created with dependency lock sha256:lockfile-old-12b, expected sha256:lockfile-current-77a." + }, + { + "code": "MISSING_INPUT_FINGERPRINT", + "severity": "blocker", + "cellId": "cell-load-data", + "message": "1 notebook input artifact(s) lack a content fingerprint." + }, + { + "code": "PRIVATE_OUTPUT_PATH", + "severity": "blocker", + "cellId": "cell-load-data", + "message": "Notebook output references a local or private filesystem path." + }, + { + "code": "SECTION_VERSION_MISMATCH", + "severity": "blocker", + "cellId": "cell-load-data", + "message": "Output targets section version sec-results-v8, expected sec-results-v9." + }, + { + "code": "STALE_NOTEBOOK_OUTPUT", + "severity": "blocker", + "cellId": "cell-load-data", + "message": "Notebook output predates the current data, dependency, or manuscript section revision." + }, + { + "code": "DUPLICATE_EXECUTION_COUNT", + "severity": "blocker", + "cellId": "cell-render-figure", + "message": "Execution count 14 appears more than once in nb-response-model." + }, + { + "code": "MISSING_RUNTIME_DIGEST", + "severity": "warning", + "cellId": "cell-load-data", + "message": "Kernel version is present but the runtime digest was not captured." + } + ], + "actions": [ + "attach_input_fingerprints:cell-load-data", + "capture_runtime_digest:cell-load-data", + "hold_notebook_outputs:notebook-output-packet-unsafe", + "rebase_output_to_current_section:cell-load-data", + "record_random_seed:cell-fit-model", + "redact_private_output_paths:cell-load-data", + "refresh_dependency_lock:cell-load-data", + "rerun_notebook_from_clean_kernel", + "rerun_stale_cell:cell-load-data", + "reset_kernel_and_run_all_cells", + "sanitize_rich_output:cell-fit-model" + ], + "reproducibilitySummary": { + "notebooks": 1, + "cells": 3, + "trustedOutputs": 2, + "blockers": 10, + "warnings": 1 + }, + "auditDigest": "93a4bc47b453aef1692c052c2ec1493e8b7d0457e2f983f30a16abb6c74fec50" +} diff --git a/collab-notebook-reproducibility-guard/reports/warning-notebook-packet.json b/collab-notebook-reproducibility-guard/reports/warning-notebook-packet.json new file mode 100644 index 00000000..ebf63ac5 --- /dev/null +++ b/collab-notebook-reproducibility-guard/reports/warning-notebook-packet.json @@ -0,0 +1,64 @@ +{ + "packetId": "notebook-output-packet-warning", + "workspaceId": "workspace-neuro-42", + "manuscriptId": "ms-neuro-response", + "status": "stage_for_reproducibility_review", + "acceptanceLanes": { + "manuscriptInsertion": "review_required", + "reviewerPreview": "watermarked", + "auditRetention": "standard" + }, + "assessedAt": "2026-05-28T10:25:00Z", + "cells": [ + { + "id": "cell-methods-summary", + "notebookId": "nb-methods-audit", + "sectionId": "methods", + "sectionVersion": "sec-methods-v3", + "executionCount": 1, + "lastExecutedAt": "2026-05-28T10:20:00Z", + "kernel": { + "name": "python", + "version": "3.11.8" + }, + "dependencyLock": { + "manager": "pip-tools", + "digest": "sha256:lockfile-methods" + }, + "stochastic": false, + "inputs": [ + { + "id": "protocol-yaml", + "digest": "sha256:protocol-v3" + } + ], + "outputs": [ + { + "mime": "text/markdown", + "content": "Protocol audit matched the locked methods section.", + "trusted": true + } + ], + "reviewState": "reproducibility_review_required" + } + ], + "findings": [ + { + "code": "MISSING_RUNTIME_DIGEST", + "severity": "warning", + "cellId": "cell-methods-summary", + "message": "Kernel version is present but the runtime digest was not captured." + } + ], + "actions": [ + "capture_runtime_digest:cell-methods-summary" + ], + "reproducibilitySummary": { + "notebooks": 1, + "cells": 1, + "trustedOutputs": 1, + "blockers": 0, + "warnings": 1 + }, + "auditDigest": "d9019926c34ff26cfb97dc1caad437a788edd94336aad7efc3e48042846a9fe8" +} diff --git a/collab-notebook-reproducibility-guard/requirements-map.md b/collab-notebook-reproducibility-guard/requirements-map.md new file mode 100644 index 00000000..8bfc5249 --- /dev/null +++ b/collab-notebook-reproducibility-guard/requirements-map.md @@ -0,0 +1,15 @@ +# Requirements Map + +Issue #12 asks for a real-time collaborative research editor with embedded Jupyter notebooks, version history, and shared review workflows. + +This slice covers the notebook-output acceptance boundary: + +| Issue #12 requirement | Coverage in this module | +| --- | --- | +| Embedded Jupyter notebooks | Validates Jupyter-style cell output packets before manuscript insertion | +| Real-time collaboration | Produces deterministic accept/review/hold lanes for collaborators | +| Version history and autosave | Checks output timestamps against current data, notebook, dependency, and section revisions | +| Inline review workflow | Emits reviewer actions and audit digests for reproducibility remediation | +| Scientific rigor | Requires runtime, dependency, seed, input, and execution-order evidence before acceptance | + +Non-overlap: this is focused on notebook output reproducibility. It does not duplicate clipboard/import provenance, local cache privacy, reference merge, data availability, LaTeX macro safety, presence liveness, suggestion provenance, or broad editor scaffolding. diff --git a/collab-notebook-reproducibility-guard/sample-data.js b/collab-notebook-reproducibility-guard/sample-data.js new file mode 100644 index 00000000..a7be06ae --- /dev/null +++ b/collab-notebook-reproducibility-guard/sample-data.js @@ -0,0 +1,235 @@ +const unsafePacket = { + packetId: 'notebook-output-packet-unsafe', + workspaceId: 'workspace-neuro-42', + manuscriptId: 'ms-neuro-response', + receivedAt: '2026-05-28T10:15:00Z', + currentDataRevisionAt: '2026-05-28T09:40:00Z', + currentNotebookRevisionAt: '2026-05-28T09:45:00Z', + currentSectionVersions: { + results: 'sec-results-v9' + }, + acceptedDependencyLocks: { + 'nb-response-model': { + digest: 'sha256:lockfile-current-77a', + updatedAt: '2026-05-28T09:50:00Z' + } + }, + cells: [ + { + id: 'cell-load-data', + notebookId: 'nb-response-model', + sectionId: 'results', + sectionVersion: 'sec-results-v8', + executionCount: 12, + lastExecutedAt: '2026-05-28T09:30:00Z', + kernel: { + name: 'python', + version: '3.11.8' + }, + dependencyLock: { + manager: 'pip-tools', + digest: 'sha256:lockfile-old-12b' + }, + stochastic: false, + inputs: [ + { id: 'cohort-export', path: 'restricted-dataset/cohort.csv' } + ], + outputs: [ + { + mime: 'text/plain', + content: 'Loaded cohort from /Users/alex/private-lab/patient-export/cohort.csv', + trusted: true + } + ] + }, + { + id: 'cell-fit-model', + notebookId: 'nb-response-model', + sectionId: 'results', + sectionVersion: 'sec-results-v9', + executionCount: 14, + lastExecutedAt: '2026-05-28T10:00:00Z', + kernel: { + name: 'python', + version: '3.11.8', + runtimeDigest: 'sha256:runtime-current' + }, + dependencyLock: { + manager: 'pip-tools', + digest: 'sha256:lockfile-current-77a' + }, + stochastic: true, + inputs: [ + { id: 'cohort-export', digest: 'sha256:cohort-stable' } + ], + outputs: [ + { + mime: 'text/html', + content: '', + trusted: false + } + ] + }, + { + id: 'cell-render-figure', + notebookId: 'nb-response-model', + sectionId: 'results', + sectionVersion: 'sec-results-v9', + executionCount: 14, + lastExecutedAt: '2026-05-28T10:04:00Z', + kernel: { + name: 'python', + version: '3.11.8', + runtimeDigest: 'sha256:runtime-current' + }, + dependencyLock: { + manager: 'pip-tools', + digest: 'sha256:lockfile-current-77a' + }, + stochastic: false, + inputs: [ + { id: 'model-summary', digest: 'sha256:model-summary' } + ], + outputs: [ + { + mime: 'image/svg+xml', + content: 'Response model', + trusted: true + } + ] + } + ] +}; + +const warningPacket = { + packetId: 'notebook-output-packet-warning', + workspaceId: 'workspace-neuro-42', + manuscriptId: 'ms-neuro-response', + receivedAt: '2026-05-28T10:25:00Z', + currentDataRevisionAt: '2026-05-28T09:40:00Z', + currentNotebookRevisionAt: '2026-05-28T09:45:00Z', + currentSectionVersions: { + methods: 'sec-methods-v3' + }, + acceptedDependencyLocks: { + 'nb-methods-audit': { + digest: 'sha256:lockfile-methods', + updatedAt: '2026-05-28T09:41:00Z' + } + }, + cells: [ + { + id: 'cell-methods-summary', + notebookId: 'nb-methods-audit', + sectionId: 'methods', + sectionVersion: 'sec-methods-v3', + executionCount: 1, + lastExecutedAt: '2026-05-28T10:20:00Z', + kernel: { + name: 'python', + version: '3.11.8' + }, + dependencyLock: { + manager: 'pip-tools', + digest: 'sha256:lockfile-methods' + }, + stochastic: false, + inputs: [ + { id: 'protocol-yaml', digest: 'sha256:protocol-v3' } + ], + outputs: [ + { + mime: 'text/markdown', + content: 'Protocol audit matched the locked methods section.', + trusted: true + } + ] + } + ] +}; + +const cleanPacket = { + packetId: 'notebook-output-packet-clean', + workspaceId: 'workspace-neuro-42', + manuscriptId: 'ms-neuro-response', + receivedAt: '2026-05-28T10:35:00Z', + currentDataRevisionAt: '2026-05-28T09:40:00Z', + currentNotebookRevisionAt: '2026-05-28T09:45:00Z', + currentSectionVersions: { + results: 'sec-results-v9' + }, + acceptedDependencyLocks: { + 'nb-response-model': { + digest: 'sha256:lockfile-current-77a', + updatedAt: '2026-05-28T09:50:00Z' + } + }, + cells: [ + { + id: 'cell-clean-load', + notebookId: 'nb-response-model', + sectionId: 'results', + sectionVersion: 'sec-results-v9', + executionCount: 1, + lastExecutedAt: '2026-05-28T10:05:00Z', + kernel: { + name: 'python', + version: '3.11.8', + runtimeDigest: 'sha256:runtime-current' + }, + dependencyLock: { + manager: 'pip-tools', + digest: 'sha256:lockfile-current-77a' + }, + stochastic: false, + inputs: [ + { id: 'cohort-export', digest: 'sha256:cohort-stable' } + ], + outputs: [ + { + mime: 'text/plain', + content: 'Loaded 128 anonymized rows from cohort artifact sha256:cohort-stable.', + trusted: true + } + ] + }, + { + id: 'cell-clean-fit', + notebookId: 'nb-response-model', + sectionId: 'results', + sectionVersion: 'sec-results-v9', + executionCount: 2, + lastExecutedAt: '2026-05-28T10:08:00Z', + kernel: { + name: 'python', + version: '3.11.8', + runtimeDigest: 'sha256:runtime-current' + }, + dependencyLock: { + manager: 'pip-tools', + digest: 'sha256:lockfile-current-77a' + }, + stochastic: true, + randomSeed: { + value: 20260528, + capturedAt: '2026-05-28T10:07:59Z' + }, + inputs: [ + { id: 'cohort-export', digest: 'sha256:cohort-stable' } + ], + outputs: [ + { + mime: 'text/markdown', + content: 'Model AUC: 0.82. Bootstrap CI generated with seed 20260528.', + trusted: true + } + ] + } + ] +}; + +module.exports = { + unsafePacket, + warningPacket, + cleanPacket +}; diff --git a/collab-notebook-reproducibility-guard/test.js b/collab-notebook-reproducibility-guard/test.js new file mode 100644 index 00000000..0132736e --- /dev/null +++ b/collab-notebook-reproducibility-guard/test.js @@ -0,0 +1,81 @@ +const assert = require('assert'); + +const { assessNotebookOutputPacket } = require('./index'); +const { cleanPacket, unsafePacket, warningPacket } = require('./sample-data'); + +function findingCodes(packet) { + return packet.findings.map((finding) => finding.code).sort(); +} + +function testHoldsUnsafeNotebookOutputsBeforeManuscriptAcceptance() { + const packet = assessNotebookOutputPacket(unsafePacket); + + assert.equal(packet.status, 'hold_notebook_outputs'); + assert.equal(packet.acceptanceLanes.manuscriptInsertion, 'blocked'); + assert.equal(packet.acceptanceLanes.reviewerPreview, 'sanitized'); + assert.equal(packet.acceptanceLanes.auditRetention, 'reproducibility_hold'); + assert.deepEqual(findingCodes(packet), [ + 'DEPENDENCY_LOCK_MISMATCH', + 'DUPLICATE_EXECUTION_COUNT', + 'DUPLICATE_EXECUTION_COUNT', + 'EXECUTION_ORDER_GAP', + 'MISSING_INPUT_FINGERPRINT', + 'MISSING_RANDOM_SEED', + 'MISSING_RUNTIME_DIGEST', + 'PRIVATE_OUTPUT_PATH', + 'SECTION_VERSION_MISMATCH', + 'STALE_NOTEBOOK_OUTPUT', + 'UNTRUSTED_RICH_HTML_OUTPUT' + ]); + + assert.ok(packet.actions.includes('hold_notebook_outputs:notebook-output-packet-unsafe')); + assert.ok(packet.actions.includes('rerun_notebook_from_clean_kernel')); + assert.ok(packet.actions.includes('reset_kernel_and_run_all_cells')); + assert.ok(packet.actions.includes('sanitize_rich_output:cell-fit-model')); + assert.ok(packet.actions.includes('redact_private_output_paths:cell-load-data')); + assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); + + const loadCell = packet.cells.find((cell) => cell.id === 'cell-load-data'); + const modelCell = packet.cells.find((cell) => cell.id === 'cell-fit-model'); + assert.match(loadCell.outputs[0].content, /\[redacted-local-path]/); + assert.equal(modelCell.outputs[0].trusted, false); + assert.doesNotMatch(modelCell.outputs[0].content, / cell.reviewState === 'ready_for_collaborative_acceptance'), true); + assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); +} + +const tests = [ + testHoldsUnsafeNotebookOutputsBeforeManuscriptAcceptance, + testStagesWarningOnlyPacketForReproducibilityReview, + testAcceptsCleanContinuousRerunPacket +]; + +for (const test of tests) { + test(); +} + +console.log(`collab-notebook-reproducibility-guard tests passed (${tests.length})`);