From fdce7769cf636edcb052c381b9c2f7f7cc87a908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E9=93=AD=E9=94=8B?= Date: Fri, 15 May 2026 19:29:58 +0800 Subject: [PATCH] Add notebook result replay ledger --- notebook-result-replay-ledger/README.md | 73 ++++ .../data/sample-repository.json | 176 +++++++++ notebook-result-replay-ledger/docs/demo.mp4 | Bin 0 -> 61453 bytes notebook-result-replay-ledger/docs/demo.svg | 52 +++ .../docs/requirement-map.md | 48 +++ notebook-result-replay-ledger/package.json | 15 + notebook-result-replay-ledger/scripts/demo.js | 17 + .../src/replay-ledger.js | 371 ++++++++++++++++++ .../test/replay-ledger.test.js | 106 +++++ 9 files changed, 858 insertions(+) create mode 100644 notebook-result-replay-ledger/README.md create mode 100644 notebook-result-replay-ledger/data/sample-repository.json create mode 100644 notebook-result-replay-ledger/docs/demo.mp4 create mode 100644 notebook-result-replay-ledger/docs/demo.svg create mode 100644 notebook-result-replay-ledger/docs/requirement-map.md create mode 100644 notebook-result-replay-ledger/package.json create mode 100644 notebook-result-replay-ledger/scripts/demo.js create mode 100644 notebook-result-replay-ledger/src/replay-ledger.js create mode 100644 notebook-result-replay-ledger/test/replay-ledger.test.js diff --git a/notebook-result-replay-ledger/README.md b/notebook-result-replay-ledger/README.md new file mode 100644 index 0000000..82cf78d --- /dev/null +++ b/notebook-result-replay-ledger/README.md @@ -0,0 +1,73 @@ +# Notebook Result Replay Ledger + +This module adds a focused computation-aware reproducibility slice for +SCIBASE project repositories. It models notebook cells, data/code/environment +dependencies, result artifacts, and release gates so a repository version can +answer one practical question before publication: + +> Which scientific outputs were generated from the current data, code, and +> runtime evidence, and which outputs must be replayed before this version is +> cited or released? + +The implementation is dependency-free and can run as a local validation step, +pre-release check, or future API service. + +## What It Covers + +- Hashes notebook inputs, code, runtime specs, and result artifacts into a + stable replay digest. +- Detects stale result outputs after upstream data, code, notebook, or + environment changes. +- Separates release-blocking failures from advisory replay work. +- Builds rollback-ready replay packets for reviewers and maintainers. +- Emits citation/version impact notes when a tagged repository version should + not advertise a clean "cite this project" badge. +- Includes tests, sample scientific repository data, a CLI demo, a requirement + map, and a short demo video. + +## Quick Start + +```bash +npm run check +npm test +npm run demo +``` + +Expected demo summary: + +```text +Release decision: block-release +Replay findings: 2 +Release-blocking artifacts: 2 +``` + +## Repository Layout + +```text +notebook-result-replay-ledger/ + data/sample-repository.json + docs/demo.svg + docs/demo.mp4 + docs/requirement-map.md + scripts/demo.js + src/replay-ledger.js + test/replay-ledger.test.js +``` + +## Design Notes + +The ledger intentionally stays narrower than a full project repository backend. +It assumes SCIBASE already has repository components such as `data/`, `code/`, +`notebooks/`, `results/`, `protocols/`, and `metadata.json`. This package adds +the reproducibility layer that ties those components together at publication +time: + +1. A notebook cell declares its data, code, notebook, and environment inputs. +2. A result artifact stores the dependency hashes used when it was last + replayed. +3. The ledger recomputes a current replay digest from the repository manifest. +4. Any mismatch, failed run, or late dependency change becomes a finding. +5. Findings are classified into release-blocking or advisory outcomes. + +That makes the result suitable for a pull request check, a release checklist, or +a reviewer-facing reproducibility panel. diff --git a/notebook-result-replay-ledger/data/sample-repository.json b/notebook-result-replay-ledger/data/sample-repository.json new file mode 100644 index 0000000..262e704 --- /dev/null +++ b/notebook-result-replay-ledger/data/sample-repository.json @@ -0,0 +1,176 @@ +{ + "schemaVersion": "replay-ledger.v1", + "repository": { + "id": "scibase-neuro-organoid-atlas", + "name": "Neuro Organoid Response Atlas", + "releaseCandidate": "preprint-v2.2-rc1", + "lastStableTag": "preprint-v2.1", + "doi": "10.5555/scibase.neuro-organoid-atlas.v2", + "citationBadge": "cite-this-project" + }, + "components": [ + { + "id": "data.raw-counts", + "kind": "data", + "path": "data/raw/organoid-expression.csv", + "hash": "sha256:data-raw-counts-v4", + "previousHash": "sha256:data-raw-counts-v3", + "changedAt": "2026-05-12T10:00:00Z", + "lastStableTag": "preprint-v2.1", + "largeFile": true + }, + { + "id": "data.qc-manifest", + "kind": "data", + "path": "data/processed/qc-manifest.json", + "hash": "sha256:data-qc-manifest-v2", + "previousHash": "sha256:data-qc-manifest-v1", + "changedAt": "2026-05-12T10:35:00Z", + "lastStableTag": "preprint-v2.1" + }, + { + "id": "code.normalizer", + "kind": "code", + "path": "code/analysis/normalize_counts.py", + "hash": "sha256:normalizer-v5", + "previousHash": "sha256:normalizer-v4", + "changedAt": "2026-05-13T08:25:00Z", + "lastStableTag": "preprint-v2.1" + }, + { + "id": "code.plotter", + "kind": "code", + "path": "code/analysis/build_figures.py", + "hash": "sha256:plotter-v2", + "previousHash": "sha256:plotter-v2", + "changedAt": "2026-05-10T12:00:00Z", + "lastStableTag": "preprint-v2.1" + }, + { + "id": "notebook.run-analysis", + "kind": "notebook", + "path": "notebooks/run_analysis.ipynb", + "hash": "sha256:run-analysis-v7", + "previousHash": "sha256:run-analysis-v6", + "changedAt": "2026-05-13T09:10:00Z", + "lastStableTag": "preprint-v2.1" + }, + { + "id": "notebook.qc-review", + "kind": "notebook", + "path": "notebooks/qc_review.ipynb", + "hash": "sha256:qc-review-v3", + "previousHash": "sha256:qc-review-v3", + "changedAt": "2026-05-11T14:00:00Z", + "lastStableTag": "preprint-v2.1" + }, + { + "id": "env.analysis", + "kind": "environment", + "path": "environment.yml", + "hash": "sha256:conda-analysis-2026-05-14", + "previousHash": "sha256:conda-analysis-2026-05-08", + "changedAt": "2026-05-14T07:45:00Z", + "lastStableTag": "preprint-v2.1" + }, + { + "id": "result.figure-1a", + "kind": "result", + "path": "results/figure-1a.png", + "hash": "sha256:figure-1a-before-data-update", + "lastReplayedAt": "2026-05-11T16:20:00Z", + "status": "passed", + "releaseCritical": true, + "citationCritical": true, + "recordedDependencyHashes": { + "data.raw-counts": "sha256:data-raw-counts-v3", + "code.normalizer": "sha256:normalizer-v4", + "code.plotter": "sha256:plotter-v2", + "notebook.run-analysis": "sha256:run-analysis-v6", + "env.analysis": "sha256:conda-analysis-2026-05-08" + } + }, + { + "id": "result.model-metrics", + "kind": "result", + "path": "results/model-metrics.json", + "hash": "sha256:model-metrics-current", + "lastReplayedAt": "2026-05-14T09:15:00Z", + "status": "passed", + "releaseCritical": true, + "citationCritical": true, + "recordedDependencyHashes": { + "data.raw-counts": "sha256:data-raw-counts-v4", + "code.normalizer": "sha256:normalizer-v5", + "notebook.run-analysis": "sha256:run-analysis-v7", + "env.analysis": "sha256:conda-analysis-2026-05-14" + } + }, + { + "id": "result.qc-table", + "kind": "result", + "path": "results/qc-table.csv", + "hash": "sha256:qc-table-failed-replay", + "lastReplayedAt": "2026-05-14T08:05:00Z", + "status": "failed", + "releaseCritical": true, + "citationCritical": false, + "failureReason": "Notebook replay stopped after container image changed.", + "recordedDependencyHashes": { + "data.qc-manifest": "sha256:data-qc-manifest-v2", + "notebook.qc-review": "sha256:qc-review-v3", + "env.analysis": "sha256:conda-analysis-2026-05-14" + } + }, + { + "id": "protocol.analysis-plan", + "kind": "protocol", + "path": "protocols/analysis-plan.md", + "hash": "sha256:analysis-plan-v2", + "changedAt": "2026-05-09T10:00:00Z", + "lastStableTag": "preprint-v2.1" + }, + { + "id": "metadata.project", + "kind": "metadata", + "path": "metadata.json", + "hash": "sha256:metadata-v4", + "changedAt": "2026-05-14T11:00:00Z", + "lastStableTag": "preprint-v2.1" + } + ], + "notebookCells": [ + { + "id": "cell.figure-1a", + "notebookId": "notebook.run-analysis", + "label": "Generate Figure 1A response heatmap", + "inputComponentIds": ["data.raw-counts"], + "codeComponentIds": ["code.normalizer", "code.plotter"], + "environmentComponentId": "env.analysis", + "outputResultIds": ["result.figure-1a"] + }, + { + "id": "cell.model-metrics", + "notebookId": "notebook.run-analysis", + "label": "Recompute classifier metrics", + "inputComponentIds": ["data.raw-counts"], + "codeComponentIds": ["code.normalizer"], + "environmentComponentId": "env.analysis", + "outputResultIds": ["result.model-metrics"] + }, + { + "id": "cell.qc-table", + "notebookId": "notebook.qc-review", + "label": "Validate sample quality table", + "inputComponentIds": ["data.qc-manifest"], + "codeComponentIds": [], + "environmentComponentId": "env.analysis", + "outputResultIds": ["result.qc-table"] + } + ], + "releaseRules": { + "blockOnFailedReplay": true, + "blockOnCriticalStaleOutputs": true, + "warnOnCitationCriticalStaleOutputs": true + } +} diff --git a/notebook-result-replay-ledger/docs/demo.mp4 b/notebook-result-replay-ledger/docs/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..ce71d54087b2d9e80cd0719b2eaa3b1266ab0424 GIT binary patch literal 61453 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≫kI@>|pQuH*hMJ;f(3EUi~3b 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 V6m%Q3@3;*L0U^!)Rd2@czW|Jsw|M{n literal 0 HcmV?d00001 diff --git a/notebook-result-replay-ledger/docs/demo.svg b/notebook-result-replay-ledger/docs/demo.svg new file mode 100644 index 0000000..fd68f25 --- /dev/null +++ b/notebook-result-replay-ledger/docs/demo.svg @@ -0,0 +1,52 @@ + + Notebook result replay ledger demo + Dashboard showing release blocked by stale scientific notebook results and failed replay evidence. + + + Notebook Result Replay Ledger + SCIBASE project repository release gate for preprint-v2.2-rc1 + + BLOCK RELEASE + + + Replay summary + 2 + findings across notebook outputs + 2 release-critical artifacts blocked + 1 citation badge impact + + + Current digest + sha256:dc54...2c76 + Current data, code, notebook, and env hashes. + + + Recorded digest + sha256:6168...8361 + Stored before upstream data/code changes. + + + Release-blocking evidence + + + + results/figure-1a.png + Data, code, notebook, and environment hashes changed after replay. + + needs replay + + + results/qc-table.csv + Notebook replay failed after the container image changed. + + failed replay + + + results/model-metrics.json + Current replay digest matches the release candidate manifest. + + clean + + + Rollback packet: preprint-v2.1:results/figure-1a.png | replay command: scibase replay cell.figure-1a --candidate preprint-v2.2-rc1 + diff --git a/notebook-result-replay-ledger/docs/requirement-map.md b/notebook-result-replay-ledger/docs/requirement-map.md new file mode 100644 index 0000000..6b9a61a --- /dev/null +++ b/notebook-result-replay-ledger/docs/requirement-map.md @@ -0,0 +1,48 @@ +# Requirement Map + +Issue: SCIBASE-AI/SCIBASE.AI#10, Project Repository & Version Control. + +## Repository Structure & Components + +The sample manifest models scientific repository components across `data/`, +`code/`, `notebooks/`, `results/`, `protocols/`, and `metadata.json`. The +ledger validates that each component has a stable id, path, kind, content hash, +and timestamp where relevant. + +## File & Metadata Versioning + +Every component carries a `sha256:` content hash plus previous hash and stable +tag metadata where useful. Result artifacts store the dependency hashes used at +the last replay, allowing the ledger to compare recorded evidence against the +current release-candidate manifest. + +## Computation-Aware Reproducibility + +Notebook cells declare their data inputs, code inputs, notebook file, runtime +spec, and result artifacts. The ledger derives a replay digest from that +evidence, detects stale result outputs after dependency changes, and classifies +failed notebook replays as release blockers. + +## Rollback And Release Gating + +Each finding creates a rollback packet with the last stable tag, result path, +dependency paths, and replay command. Release-critical stale artifacts return +`block-release`; clean artifacts stay out of the findings list. + +## Repository Identifiers & Citation + +Citation-critical results generate impact notes when the release candidate +should not present a clean citation badge. This keeps DOI/version metadata from +overstating reproducibility before a result is replayed. + +## Programmatic Access & Export + +The module exports pure functions (`createReplayLedger`, +`validateRepositoryManifest`, `createReplaySnapshot`) that can back a REST +endpoint, CLI command, pull request check, or export-bundle validator. + +## Scope Boundary + +This is not another broad repository implementation. It is a focused +reproducibility layer for notebook/result replay evidence inside the project +repository model described by the bounty. diff --git a/notebook-result-replay-ledger/package.json b/notebook-result-replay-ledger/package.json new file mode 100644 index 0000000..cb72958 --- /dev/null +++ b/notebook-result-replay-ledger/package.json @@ -0,0 +1,15 @@ +{ + "name": "notebook-result-replay-ledger", + "version": "0.1.0", + "description": "Dependency-free replay ledger for scientific notebook outputs and release gates.", + "private": true, + "scripts": { + "check": "node scripts/demo.js --json > /dev/null", + "demo": "node scripts/demo.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/notebook-result-replay-ledger/scripts/demo.js b/notebook-result-replay-ledger/scripts/demo.js new file mode 100644 index 0000000..1cc2394 --- /dev/null +++ b/notebook-result-replay-ledger/scripts/demo.js @@ -0,0 +1,17 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { createReplayLedger, formatLedgerReport } = require("../src/replay-ledger"); + +const manifestPath = path.join(__dirname, "..", "data", "sample-repository.json"); +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const ledger = createReplayLedger(manifest); + +if (process.argv.includes("--json")) { + process.stdout.write(`${JSON.stringify(ledger, null, 2)}\n`); +} else { + process.stdout.write(`${formatLedgerReport(ledger)}\n`); +} + +if (ledger.releaseDecision === "invalid-manifest") { + process.exitCode = 1; +} diff --git a/notebook-result-replay-ledger/src/replay-ledger.js b/notebook-result-replay-ledger/src/replay-ledger.js new file mode 100644 index 0000000..6e8052d --- /dev/null +++ b/notebook-result-replay-ledger/src/replay-ledger.js @@ -0,0 +1,371 @@ +const crypto = require("node:crypto"); + +const REQUIRED_COMPONENT_KINDS = new Set([ + "data", + "code", + "notebook", + "result", + "environment", + "protocol", + "metadata" +]); + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + + if (value && typeof value === "object") { + const keys = Object.keys(value).sort(); + return `{${keys + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +function digest(value) { + return `sha256:${crypto.createHash("sha256").update(stableStringify(value)).digest("hex")}`; +} + +function assertIsoDate(value, label, errors) { + if (!value || Number.isNaN(Date.parse(value))) { + errors.push(`${label} must be an ISO timestamp`); + } +} + +function buildComponentIndex(manifest) { + return new Map((manifest.components || []).map((component) => [component.id, component])); +} + +function requireComponent(index, id, label) { + const component = index.get(id); + if (!component) { + throw new Error(`Missing ${label} component: ${id}`); + } + return component; +} + +function componentReference(component, hashOverride) { + return { + id: component.id, + kind: component.kind, + path: component.path, + hash: hashOverride || component.hash + }; +} + +function createReplaySnapshot(manifest, cell, recordedHashes = {}) { + const index = buildComponentIndex(manifest); + const notebook = requireComponent(index, cell.notebookId, "notebook"); + const environment = requireComponent(index, cell.environmentComponentId, "environment"); + const inputComponents = cell.inputComponentIds.map((id) => requireComponent(index, id, "input")); + const codeComponents = cell.codeComponentIds.map((id) => requireComponent(index, id, "code")); + + return { + repositoryId: manifest.repository.id, + releaseCandidate: manifest.repository.releaseCandidate, + cellId: cell.id, + cellLabel: cell.label, + notebook: componentReference(notebook, recordedHashes[notebook.id]), + inputs: inputComponents.map((component) => componentReference(component, recordedHashes[component.id])), + code: codeComponents.map((component) => componentReference(component, recordedHashes[component.id])), + environment: componentReference(environment, recordedHashes[environment.id]) + }; +} + +function listCellDependencyIds(cell) { + return [ + cell.notebookId, + ...cell.inputComponentIds, + ...cell.codeComponentIds, + cell.environmentComponentId + ]; +} + +function changedAfterReplay(component, result) { + if (!component.changedAt || !result.lastReplayedAt) { + return false; + } + + return Date.parse(component.changedAt) > Date.parse(result.lastReplayedAt); +} + +function buildReplayFinding(manifest, cell, result) { + const index = buildComponentIndex(manifest); + const dependencyIds = listCellDependencyIds(cell); + const currentSnapshot = createReplaySnapshot(manifest, cell); + const recordedSnapshot = createReplaySnapshot(manifest, cell, result.recordedDependencyHashes || {}); + const currentDigest = digest(currentSnapshot); + const recordedDigest = digest(recordedSnapshot); + const staleDependencies = dependencyIds + .map((id) => requireComponent(index, id, "dependency")) + .filter((component) => { + const recordedHash = result.recordedDependencyHashes?.[component.id]; + return recordedHash && recordedHash !== component.hash; + }) + .map((component) => ({ + id: component.id, + path: component.path, + kind: component.kind, + recordedHash: result.recordedDependencyHashes[component.id], + currentHash: component.hash, + changedAt: component.changedAt + })); + + const lateChanges = dependencyIds + .map((id) => requireComponent(index, id, "dependency")) + .filter((component) => changedAfterReplay(component, result)) + .map((component) => ({ + id: component.id, + path: component.path, + kind: component.kind, + changedAt: component.changedAt + })); + + const problems = []; + if (result.status === "failed") { + problems.push({ + code: "failed-replay", + message: result.failureReason || "Notebook replay did not complete." + }); + } + + if (staleDependencies.length > 0) { + problems.push({ + code: "stale-dependency-hash", + message: "Recorded replay dependencies no longer match the repository manifest." + }); + } + + if (lateChanges.length > 0) { + problems.push({ + code: "dependency-changed-after-replay", + message: "One or more dependencies changed after the result was last replayed." + }); + } + + const severity = result.releaseCritical && problems.length > 0 ? "block-release" : "advisory"; + + return { + resultId: result.id, + resultPath: result.path, + cellId: cell.id, + cellLabel: cell.label, + releaseCritical: Boolean(result.releaseCritical), + citationCritical: Boolean(result.citationCritical), + lastReplayedAt: result.lastReplayedAt, + resultHash: result.hash, + currentReplayDigest: currentDigest, + recordedReplayDigest: recordedDigest, + staleDependencies, + lateChanges, + problems, + severity + }; +} + +function validateRepositoryManifest(manifest) { + const errors = []; + + if (manifest.schemaVersion !== "replay-ledger.v1") { + errors.push("schemaVersion must be replay-ledger.v1"); + } + + if (!manifest.repository?.id) { + errors.push("repository.id is required"); + } + + if (!manifest.repository?.releaseCandidate) { + errors.push("repository.releaseCandidate is required"); + } + + const seenIds = new Set(); + const kinds = new Set(); + for (const component of manifest.components || []) { + if (!component.id) { + errors.push("every component requires an id"); + } + if (seenIds.has(component.id)) { + errors.push(`duplicate component id: ${component.id}`); + } + seenIds.add(component.id); + + if (!component.kind || !REQUIRED_COMPONENT_KINDS.has(component.kind)) { + errors.push(`component ${component.id || ""} has unsupported kind`); + } else { + kinds.add(component.kind); + } + + if (!component.path) { + errors.push(`component ${component.id || ""} requires a path`); + } + if (!component.hash?.startsWith("sha256:")) { + errors.push(`component ${component.id || ""} requires a sha256 hash`); + } + if (component.changedAt) { + assertIsoDate(component.changedAt, `${component.id}.changedAt`, errors); + } + if (component.lastReplayedAt) { + assertIsoDate(component.lastReplayedAt, `${component.id}.lastReplayedAt`, errors); + } + } + + for (const kind of REQUIRED_COMPONENT_KINDS) { + if (!kinds.has(kind)) { + errors.push(`repository manifest should include at least one ${kind} component`); + } + } + + const index = buildComponentIndex(manifest); + for (const cell of manifest.notebookCells || []) { + if (!cell.id) { + errors.push("every notebook cell requires an id"); + } + if (!index.has(cell.notebookId)) { + errors.push(`cell ${cell.id} references missing notebook ${cell.notebookId}`); + } + if (!index.has(cell.environmentComponentId)) { + errors.push(`cell ${cell.id} references missing environment ${cell.environmentComponentId}`); + } + for (const id of [...(cell.inputComponentIds || []), ...(cell.codeComponentIds || []), ...(cell.outputResultIds || [])]) { + if (!index.has(id)) { + errors.push(`cell ${cell.id} references missing component ${id}`); + } + } + } + + return errors; +} + +function buildRollbackPacket(manifest, finding) { + const stalePaths = finding.staleDependencies.map((dependency) => dependency.path); + const replayCommand = `scibase replay ${finding.cellId} --candidate ${manifest.repository.releaseCandidate}`; + + return { + resultId: finding.resultId, + resultPath: finding.resultPath, + lastKnownGoodTag: manifest.repository.lastStableTag, + rollbackTarget: `${manifest.repository.lastStableTag}:${finding.resultPath}`, + replayCommand, + dependencyPaths: stalePaths, + reviewerNote: + finding.severity === "block-release" + ? "Hold the release until this notebook cell is replayed with the current manifest." + : "Replay recommended before advertising this output as current." + }; +} + +function buildCitationImpact(manifest, findings) { + return findings + .filter((finding) => finding.citationCritical && finding.problems.length > 0) + .map((finding) => ({ + resultId: finding.resultId, + badge: manifest.repository.citationBadge, + releaseCandidate: manifest.repository.releaseCandidate, + note: `${manifest.repository.releaseCandidate} should not show a clean citation badge until ${finding.resultPath} is replayed.` + })); +} + +function createReplayLedger(manifest) { + const validationErrors = validateRepositoryManifest(manifest); + if (validationErrors.length > 0) { + return { + valid: false, + validationErrors, + releaseDecision: "invalid-manifest", + findings: [], + rollbackPackets: [], + citationImpacts: [], + summary: { + totalResults: 0, + replayFindings: 0, + releaseBlockingArtifacts: 0, + citationImpacts: 0 + } + }; + } + + const index = buildComponentIndex(manifest); + const allFindings = []; + for (const cell of manifest.notebookCells) { + for (const resultId of cell.outputResultIds) { + const result = requireComponent(index, resultId, "result"); + const finding = buildReplayFinding(manifest, cell, result); + if (finding.problems.length > 0) { + allFindings.push(finding); + } + } + } + + const releaseBlockingFindings = allFindings.filter((finding) => finding.severity === "block-release"); + const releaseDecision = releaseBlockingFindings.length > 0 ? "block-release" : "ready"; + const rollbackPackets = allFindings.map((finding) => buildRollbackPacket(manifest, finding)); + const citationImpacts = buildCitationImpact(manifest, allFindings); + const summary = { + repositoryId: manifest.repository.id, + releaseCandidate: manifest.repository.releaseCandidate, + totalResults: manifest.components.filter((component) => component.kind === "result").length, + replayFindings: allFindings.length, + releaseBlockingArtifacts: releaseBlockingFindings.length, + citationImpacts: citationImpacts.length + }; + + return { + valid: true, + validationErrors: [], + releaseDecision, + findings: allFindings, + rollbackPackets, + citationImpacts, + summary, + auditDigest: digest({ + summary, + findings: allFindings.map((finding) => ({ + resultId: finding.resultId, + severity: finding.severity, + currentReplayDigest: finding.currentReplayDigest, + recordedReplayDigest: finding.recordedReplayDigest + })) + }) + }; +} + +function formatLedgerReport(ledger) { + if (!ledger.valid) { + return [`Manifest invalid:`, ...ledger.validationErrors.map((error) => `- ${error}`)].join("\n"); + } + + const lines = [ + `Repository: ${ledger.summary.repositoryId}`, + `Release candidate: ${ledger.summary.releaseCandidate}`, + `Release decision: ${ledger.releaseDecision}`, + `Replay findings: ${ledger.summary.replayFindings}`, + `Release-blocking artifacts: ${ledger.summary.releaseBlockingArtifacts}`, + `Citation impacts: ${ledger.summary.citationImpacts}`, + `Audit digest: ${ledger.auditDigest}`, + "" + ]; + + for (const finding of ledger.findings) { + lines.push(`${finding.severity.toUpperCase()} ${finding.resultPath}`); + lines.push(` cell: ${finding.cellLabel}`); + lines.push(` current digest: ${finding.currentReplayDigest}`); + lines.push(` recorded digest: ${finding.recordedReplayDigest}`); + for (const problem of finding.problems) { + lines.push(` - ${problem.code}: ${problem.message}`); + } + } + + return lines.join("\n"); +} + +module.exports = { + createReplayLedger, + createReplaySnapshot, + digest, + formatLedgerReport, + stableStringify, + validateRepositoryManifest +}; diff --git a/notebook-result-replay-ledger/test/replay-ledger.test.js b/notebook-result-replay-ledger/test/replay-ledger.test.js new file mode 100644 index 0000000..bfd74bb --- /dev/null +++ b/notebook-result-replay-ledger/test/replay-ledger.test.js @@ -0,0 +1,106 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const { + createReplayLedger, + digest, + stableStringify, + validateRepositoryManifest +} = require("../src/replay-ledger"); + +const samplePath = path.join(__dirname, "..", "data", "sample-repository.json"); + +function loadSample() { + return JSON.parse(fs.readFileSync(samplePath, "utf8")); +} + +function findComponent(manifest, id) { + return manifest.components.find((component) => component.id === id); +} + +test("detects stale scientific outputs after data, code, notebook, and environment changes", () => { + const ledger = createReplayLedger(loadSample()); + const figureFinding = ledger.findings.find((finding) => finding.resultId === "result.figure-1a"); + + assert.equal(ledger.releaseDecision, "block-release"); + assert.equal(figureFinding.severity, "block-release"); + assert.deepEqual( + figureFinding.staleDependencies.map((dependency) => dependency.id).sort(), + ["code.normalizer", "data.raw-counts", "env.analysis", "notebook.run-analysis"] + ); + assert.match(figureFinding.currentReplayDigest, /^sha256:[a-f0-9]{64}$/); + assert.notEqual(figureFinding.currentReplayDigest, figureFinding.recordedReplayDigest); +}); + +test("flags failed replay runs even when dependency hashes match", () => { + const ledger = createReplayLedger(loadSample()); + const qcFinding = ledger.findings.find((finding) => finding.resultId === "result.qc-table"); + + assert.equal(qcFinding.severity, "block-release"); + assert.equal(qcFinding.staleDependencies.length, 0); + assert.equal(qcFinding.problems.some((problem) => problem.code === "failed-replay"), true); +}); + +test("keeps current replayed outputs out of the findings list", () => { + const ledger = createReplayLedger(loadSample()); + + assert.equal( + ledger.findings.some((finding) => finding.resultId === "result.model-metrics"), + false + ); +}); + +test("builds rollback packets and citation impacts for reviewer workflow", () => { + const ledger = createReplayLedger(loadSample()); + + assert.equal(ledger.rollbackPackets.length, ledger.findings.length); + assert.equal( + ledger.rollbackPackets.some((packet) => packet.rollbackTarget.endsWith(":results/figure-1a.png")), + true + ); + assert.deepEqual( + ledger.citationImpacts.map((impact) => impact.resultId), + ["result.figure-1a"] + ); +}); + +test("reports ready when all release-critical results are current and replayed", () => { + const manifest = loadSample(); + const result = findComponent(manifest, "result.figure-1a"); + result.lastReplayedAt = "2026-05-14T10:20:00Z"; + result.recordedDependencyHashes = { + "data.raw-counts": "sha256:data-raw-counts-v4", + "code.normalizer": "sha256:normalizer-v5", + "code.plotter": "sha256:plotter-v2", + "notebook.run-analysis": "sha256:run-analysis-v7", + "env.analysis": "sha256:conda-analysis-2026-05-14" + }; + + const qcResult = findComponent(manifest, "result.qc-table"); + qcResult.status = "passed"; + delete qcResult.failureReason; + + const ledger = createReplayLedger(manifest); + + assert.equal(ledger.releaseDecision, "ready"); + assert.equal(ledger.summary.releaseBlockingArtifacts, 0); +}); + +test("validates repository manifest shape", () => { + const manifest = loadSample(); + manifest.components = manifest.components.filter((component) => component.kind !== "metadata"); + + const errors = validateRepositoryManifest(manifest); + + assert.equal(errors.some((error) => error.includes("metadata component")), true); +}); + +test("uses deterministic stable digests for audit records", () => { + const left = { b: 2, a: { d: 4, c: 3 } }; + const right = { a: { c: 3, d: 4 }, b: 2 }; + + assert.equal(stableStringify(left), stableStringify(right)); + assert.equal(digest(left), digest(right)); + assert.match(digest(left), /^sha256:[a-f0-9]{64}$/); +});