From 3d059105ffbe2cc35e045fc5f9afaa7791a55d7b Mon Sep 17 00:00:00 2001 From: Kyle Tree Date: Wed, 20 May 2026 03:53:20 -0700 Subject: [PATCH] Add raw instrument preview integrity gate --- README.md | 4 + .../README.md | 32 ++ raw-instrument-preview-integrity-gate/demo.js | 17 + .../demo.mp4 | Bin 0 -> 30692 bytes .../demo.svg | 1 + .../index.js | 278 +++++++++++++++ .../preview-report.json | 322 ++++++++++++++++++ .../requirements-map.md | 21 ++ .../sample-data.js | 136 ++++++++ raw-instrument-preview-integrity-gate/test.js | 62 ++++ 10 files changed, 873 insertions(+) create mode 100644 raw-instrument-preview-integrity-gate/README.md create mode 100644 raw-instrument-preview-integrity-gate/demo.js create mode 100644 raw-instrument-preview-integrity-gate/demo.mp4 create mode 100644 raw-instrument-preview-integrity-gate/demo.svg create mode 100644 raw-instrument-preview-integrity-gate/index.js create mode 100644 raw-instrument-preview-integrity-gate/preview-report.json create mode 100644 raw-instrument-preview-integrity-gate/requirements-map.md create mode 100644 raw-instrument-preview-integrity-gate/sample-data.js create mode 100644 raw-instrument-preview-integrity-gate/test.js diff --git a/README.md b/README.md index d338cf6..4a5261b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Scientific Data & Code Hosting + +- `raw-instrument-preview-integrity-gate/` adds a self-contained #14 slice for raw instrument preview safety, checksum/metadata drift checks, and DataCite/schema.org preview packets. diff --git a/raw-instrument-preview-integrity-gate/README.md b/raw-instrument-preview-integrity-gate/README.md new file mode 100644 index 0000000..7f3679d --- /dev/null +++ b/raw-instrument-preview-integrity-gate/README.md @@ -0,0 +1,32 @@ +# Raw Instrument Preview Integrity Gate + +This module is a focused Scientific/Engineering Data & Code Hosting slice for SCIBASE issue #14. It protects previews for raw instrument outputs so researchers can inspect hosted data without accidentally exposing raw restricted values or drifting away from the original instrument metadata. + +## What It Adds + +- Raw instrument type classification for `.fcs`, `.mzml`, `.nd2`, and `.h5` artifacts. +- Checksum, unit, calibration, instrument metadata, license, and embargo checks before preview generation. +- Preview transform validation for deterministic lineage-preserving downsampling. +- DataCite and schema.org packets that preserve preview decisions and technical metadata. +- Reviewer queues for blocked and metadata-only previews. +- Offline JSON and SVG demo output from synthetic instrument artifacts. + +## Why This Is Distinct + +Existing #14 submissions cover broad FAIR manifests, access/compute-run governance, executable environment drift, provenance chains, artifact quarantine, storage quotas, FAIR access gates, and artifact package integrity. This slice focuses specifically on raw instrument output preview safety and metadata-preserving transformations. + +## Run + +```bash +node raw-instrument-preview-integrity-gate/test.js +node raw-instrument-preview-integrity-gate/demo.js +``` + +The demo writes: + +- `raw-instrument-preview-integrity-gate/preview-report.json` +- `raw-instrument-preview-integrity-gate/demo.svg` + +## Decision Policy + +Checksum drift or high-risk transform failures block preview generation. Unit or calibration drift falls back to metadata-only previews. Stable artifacts with lineage-preserving transforms receive safe preview descriptors. diff --git a/raw-instrument-preview-integrity-gate/demo.js b/raw-instrument-preview-integrity-gate/demo.js new file mode 100644 index 0000000..c8cd782 --- /dev/null +++ b/raw-instrument-preview-integrity-gate/demo.js @@ -0,0 +1,17 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + buildPreviewIntegrityGate, + renderPreviewSvg +} = require("./index"); +const sampleData = require("./sample-data"); + +const report = buildPreviewIntegrityGate(sampleData); +const outDir = __dirname; + +fs.writeFileSync(path.join(outDir, "preview-report.json"), `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(path.join(outDir, "demo.svg"), renderPreviewSvg(report)); + +console.log(JSON.stringify(report, null, 2)); diff --git a/raw-instrument-preview-integrity-gate/demo.mp4 b/raw-instrument-preview-integrity-gate/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..1fdd2e2376355bfbc551690767e57fb524ddc283 GIT binary patch literal 30692 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 Instrument Preview Integrity GatePreserves calibration, units, checksums, and safe preview policy for instrument outputs.metabolomics-run.mzmlmzml | block_preview | score 100sensor-sweep.h5h5 | metadata_only_preview | score 42microglia-panel.fcsfcs | allow_safe_preview | score 18organoid-stack.nd2nd2 | allow_safe_preview | score 18 \ No newline at end of file diff --git a/raw-instrument-preview-integrity-gate/index.js b/raw-instrument-preview-integrity-gate/index.js new file mode 100644 index 0000000..207d6ab --- /dev/null +++ b/raw-instrument-preview-integrity-gate/index.js @@ -0,0 +1,278 @@ +"use strict"; + +const SUPPORTED_RAW_TYPES = Object.freeze({ + fcs: { + label: "Flow cytometry standard", + requiredMetadata: ["instrumentModel", "detectorPanel", "compensationMatrix", "units"], + previewKind: "gated_scatter_summary" + }, + mzml: { + label: "Mass spectrometry mzML", + requiredMetadata: ["instrumentModel", "ionMode", "mzRange", "units"], + previewKind: "peak_intensity_summary" + }, + nd2: { + label: "Microscopy ND2 image stack", + requiredMetadata: ["instrumentModel", "objective", "channelMap", "pixelSizeMicrons", "units"], + previewKind: "channel_thumbnail_contact_sheet" + }, + h5: { + label: "Instrument HDF5 bundle", + requiredMetadata: ["instrumentModel", "schemaVersion", "axisMap", "units"], + previewKind: "dataset_tree_summary" + } +}); + +function assertArray(value, name) { + if (!Array.isArray(value)) throw new TypeError(`${name} must be an array`); +} + +function round(value, digits = 3) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function extensionFor(fileName) { + return String(fileName || "").split(".").pop().toLowerCase(); +} + +function checksumChanged(artifact) { + return Boolean(artifact.expectedChecksum && artifact.actualChecksum && artifact.expectedChecksum !== artifact.actualChecksum); +} + +function metadataDiff(artifact) { + const expected = artifact.expectedMetadata || {}; + const observed = artifact.observedMetadata || {}; + const keys = new Set([...Object.keys(expected), ...Object.keys(observed)]); + const diffs = []; + for (const key of keys) { + if (JSON.stringify(expected[key]) !== JSON.stringify(observed[key])) { + diffs.push({ field: key, expected: expected[key] ?? null, observed: observed[key] ?? null }); + } + } + return diffs; +} + +function missingRequiredMetadata(artifact, typeSpec) { + const observed = artifact.observedMetadata || {}; + return typeSpec.requiredMetadata.filter((field) => observed[field] === undefined || observed[field] === null || observed[field] === ""); +} + +function unitDrift(artifact) { + const expectedUnits = artifact.expectedMetadata?.units || {}; + const observedUnits = artifact.observedMetadata?.units || {}; + const fields = new Set([...Object.keys(expectedUnits), ...Object.keys(observedUnits)]); + return [...fields].filter((field) => expectedUnits[field] !== observedUnits[field]); +} + +function calibrationDrift(artifact) { + const expected = artifact.expectedMetadata?.calibration || {}; + const observed = artifact.observedMetadata?.calibration || {}; + const fields = new Set([...Object.keys(expected), ...Object.keys(observed)]); + return [...fields].filter((field) => { + const left = Number(expected[field]); + const right = Number(observed[field]); + if (!Number.isFinite(left) || !Number.isFinite(right)) return expected[field] !== observed[field]; + const denominator = Math.max(Math.abs(left), 1); + return Math.abs(left - right) / denominator > Number(artifact.calibrationTolerance || 0.02); + }); +} + +function licenseRisk(artifact) { + if (artifact.license === "restricted" && artifact.previewPolicy !== "metadata-only") { + return "restricted_license_requires_metadata_only_preview"; + } + return null; +} + +function validatePreviewTransform(transform) { + const failures = []; + if (!transform) { + failures.push("preview transform is missing"); + return failures; + } + if (transform.requiresRawValueExport) failures.push("preview transform would expose raw values"); + if (!transform.deterministic) failures.push("preview transform is not deterministic"); + if (!transform.recordsLineage) failures.push("preview transform does not record lineage"); + if (transform.downsampleRatio !== undefined && Number(transform.downsampleRatio) > 0.25) { + failures.push("preview transform downsample ratio is too high for safe preview"); + } + return failures; +} + +function previewDecision(artifact) { + const type = extensionFor(artifact.fileName); + const typeSpec = SUPPORTED_RAW_TYPES[type]; + const reasons = []; + if (!typeSpec) { + return { + artifactId: artifact.id, + fileName: artifact.fileName, + rawType: type, + previewKind: "unsupported", + decision: "block_preview", + severity: "high", + score: 76, + reasons: [`unsupported raw instrument type: ${type}`], + datacite: null, + schemaOrg: null + }; + } + + if (checksumChanged(artifact)) reasons.push("raw artifact checksum changed"); + const diffs = metadataDiff(artifact); + const missing = missingRequiredMetadata(artifact, typeSpec); + const unitFields = unitDrift(artifact); + const calibrationFields = calibrationDrift(artifact); + const transformFailures = validatePreviewTransform(artifact.previewTransform); + const licenseFailure = licenseRisk(artifact); + + if (missing.length) reasons.push(`missing required metadata: ${missing.join(", ")}`); + if (unitFields.length) reasons.push(`unit metadata drift: ${unitFields.join(", ")}`); + if (calibrationFields.length) reasons.push(`calibration drift: ${calibrationFields.join(", ")}`); + if (diffs.some((diff) => diff.field === "instrumentModel")) reasons.push("instrument model drift"); + if (transformFailures.length) reasons.push(...transformFailures); + if (licenseFailure) reasons.push(licenseFailure); + if (artifact.embargoed && artifact.previewPolicy !== "metadata-only") reasons.push("embargoed artifact requires metadata-only preview"); + + const score = Math.min( + 100, + 18 + + (checksumChanged(artifact) ? 32 : 0) + + missing.length * 10 + + unitFields.length * 12 + + calibrationFields.length * 12 + + transformFailures.length * 11 + + (licenseFailure ? 20 : 0) + + (artifact.embargoed && artifact.previewPolicy !== "metadata-only" ? 20 : 0) + ); + + const decision = score >= 70 || checksumChanged(artifact) ? "block_preview" : score >= 40 ? "metadata_only_preview" : "allow_safe_preview"; + const severity = decision === "block_preview" ? "high" : decision === "metadata_only_preview" ? "medium" : "low"; + + return { + artifactId: artifact.id, + fileName: artifact.fileName, + rawType: type, + previewKind: decision === "allow_safe_preview" ? typeSpec.previewKind : "metadata_packet", + decision, + severity, + score: round(score), + reasons: [...new Set(reasons)], + datacite: buildDataCitePacket(artifact, typeSpec, decision), + schemaOrg: buildSchemaOrgPacket(artifact, typeSpec, decision) + }; +} + +function buildDataCitePacket(artifact, typeSpec, decision) { + return { + identifier: artifact.doi || artifact.id, + creators: artifact.creators || [], + titles: [{ title: artifact.title }], + publisher: artifact.publisher || "SCIBASE synthetic repository", + publicationYear: artifact.publicationYear || new Date().getUTCFullYear(), + types: { resourceTypeGeneral: "Dataset", resourceType: typeSpec.label }, + rightsList: [{ rights: artifact.license || "unknown" }], + descriptions: [ + { + descriptionType: "TechnicalInfo", + description: `Preview decision: ${decision}; transform: ${artifact.previewTransform?.name || "none"}` + } + ] + }; +} + +function buildSchemaOrgPacket(artifact, typeSpec, decision) { + return { + "@context": "https://schema.org", + "@type": "Dataset", + identifier: artifact.doi || artifact.id, + name: artifact.title, + measurementTechnique: typeSpec.label, + license: artifact.license, + encodingFormat: extensionFor(artifact.fileName), + variableMeasured: Object.keys(artifact.observedMetadata?.units || {}), + additionalProperty: [ + { "@type": "PropertyValue", name: "previewDecision", value: decision }, + { "@type": "PropertyValue", name: "previewKind", value: typeSpec.previewKind }, + { "@type": "PropertyValue", name: "actualChecksum", value: artifact.actualChecksum } + ] + }; +} + +function buildPreviewIntegrityGate(input) { + const data = input || {}; + assertArray(data.artifacts, "artifacts"); + const decisions = data.artifacts.map(previewDecision).sort((a, b) => b.score - a.score || a.fileName.localeCompare(b.fileName)); + return { + generatedAt: new Date().toISOString(), + decisions, + stats: { + artifactCount: decisions.length, + blocked: decisions.filter((decision) => decision.decision === "block_preview").length, + metadataOnly: decisions.filter((decision) => decision.decision === "metadata_only_preview").length, + safePreview: decisions.filter((decision) => decision.decision === "allow_safe_preview").length + }, + reviewerQueue: decisions + .filter((decision) => decision.decision !== "allow_safe_preview") + .map((decision) => ({ + artifactId: decision.artifactId, + fileName: decision.fileName, + decision: decision.decision, + reasons: decision.reasons + })) + }; +} + +function renderPreviewSvg(report) { + const width = 940; + const rowHeight = 84; + const height = 124 + report.decisions.length * rowHeight; + const rows = report.decisions + .map((decision, index) => { + const y = 90 + index * rowHeight; + const color = + decision.decision === "block_preview" + ? "#be123c" + : decision.decision === "metadata_only_preview" + ? "#b45309" + : "#15803d"; + return [ + ``, + ``, + `${escapeXml(decision.fileName)}`, + `${escapeXml(decision.rawType)} | ${escapeXml(decision.decision)} | score ${decision.score}`, + ``, + ``, + `` + ].join(""); + }) + .join(""); + return [ + ``, + ``, + `Raw Instrument Preview Integrity Gate`, + `Preserves calibration, units, checksums, and safe preview policy for instrument outputs.`, + rows, + `` + ].join(""); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +module.exports = { + SUPPORTED_RAW_TYPES, + buildPreviewIntegrityGate, + checksumChanged, + metadataDiff, + previewDecision, + renderPreviewSvg, + unitDrift, + calibrationDrift +}; diff --git a/raw-instrument-preview-integrity-gate/preview-report.json b/raw-instrument-preview-integrity-gate/preview-report.json new file mode 100644 index 0000000..f7a3297 --- /dev/null +++ b/raw-instrument-preview-integrity-gate/preview-report.json @@ -0,0 +1,322 @@ +{ + "generatedAt": "2026-05-20T10:53:17.035Z", + "decisions": [ + { + "artifactId": "artifact:mzml-002", + "fileName": "metabolomics-run.mzml", + "rawType": "mzml", + "previewKind": "metadata_packet", + "decision": "block_preview", + "severity": "high", + "score": 100, + "reasons": [ + "raw artifact checksum changed", + "unit metadata drift: intensity", + "calibration drift: mz", + "preview transform would expose raw values", + "preview transform does not record lineage", + "preview transform downsample ratio is too high for safe preview", + "restricted_license_requires_metadata_only_preview", + "embargoed artifact requires metadata-only preview" + ], + "datacite": { + "identifier": "10.0000/scibase.mzml.002", + "creators": [ + "SCIBASE Metabolomics Core" + ], + "titles": [ + { + "title": "Embargoed metabolomics peak run" + } + ], + "publisher": "SCIBASE synthetic repository", + "publicationYear": 2026, + "types": { + "resourceTypeGeneral": "Dataset", + "resourceType": "Mass spectrometry mzML" + }, + "rightsList": [ + { + "rights": "restricted" + } + ], + "descriptions": [ + { + "descriptionType": "TechnicalInfo", + "description": "Preview decision: block_preview; transform: peak-topline" + } + ] + }, + "schemaOrg": { + "@context": "https://schema.org", + "@type": "Dataset", + "identifier": "10.0000/scibase.mzml.002", + "name": "Embargoed metabolomics peak run", + "measurementTechnique": "Mass spectrometry mzML", + "license": "restricted", + "encodingFormat": "mzml", + "variableMeasured": [ + "mz", + "intensity" + ], + "additionalProperty": [ + { + "@type": "PropertyValue", + "name": "previewDecision", + "value": "block_preview" + }, + { + "@type": "PropertyValue", + "name": "previewKind", + "value": "peak_intensity_summary" + }, + { + "@type": "PropertyValue", + "name": "actualChecksum", + "value": "sha256:mzml-after" + } + ] + } + }, + { + "artifactId": "artifact:h5-004", + "fileName": "sensor-sweep.h5", + "rawType": "h5", + "previewKind": "metadata_packet", + "decision": "metadata_only_preview", + "severity": "medium", + "score": 42, + "reasons": [ + "unit metadata drift: impedance", + "calibration drift: impedance" + ], + "datacite": { + "identifier": "artifact:h5-004", + "creators": [ + "SCIBASE Materials Core" + ], + "titles": [ + { + "title": "Sensor HDF5 sweep bundle" + } + ], + "publisher": "SCIBASE synthetic repository", + "publicationYear": 2026, + "types": { + "resourceTypeGeneral": "Dataset", + "resourceType": "Instrument HDF5 bundle" + }, + "rightsList": [ + { + "rights": "CC0-1.0" + } + ], + "descriptions": [ + { + "descriptionType": "TechnicalInfo", + "description": "Preview decision: metadata_only_preview; transform: dataset-tree-only" + } + ] + }, + "schemaOrg": { + "@context": "https://schema.org", + "@type": "Dataset", + "identifier": "artifact:h5-004", + "name": "Sensor HDF5 sweep bundle", + "measurementTechnique": "Instrument HDF5 bundle", + "license": "CC0-1.0", + "encodingFormat": "h5", + "variableMeasured": [ + "frequency", + "impedance" + ], + "additionalProperty": [ + { + "@type": "PropertyValue", + "name": "previewDecision", + "value": "metadata_only_preview" + }, + { + "@type": "PropertyValue", + "name": "previewKind", + "value": "dataset_tree_summary" + }, + { + "@type": "PropertyValue", + "name": "actualChecksum", + "value": "sha256:h5-stable" + } + ] + } + }, + { + "artifactId": "artifact:fcs-001", + "fileName": "microglia-panel.fcs", + "rawType": "fcs", + "previewKind": "gated_scatter_summary", + "decision": "allow_safe_preview", + "severity": "low", + "score": 18, + "reasons": [], + "datacite": { + "identifier": "10.0000/scibase.fcs.001", + "creators": [ + "SCIBASE Flow Core" + ], + "titles": [ + { + "title": "Microglia cytokine flow panel" + } + ], + "publisher": "SCIBASE synthetic repository", + "publicationYear": 2026, + "types": { + "resourceTypeGeneral": "Dataset", + "resourceType": "Flow cytometry standard" + }, + "rightsList": [ + { + "rights": "CC-BY-4.0" + } + ], + "descriptions": [ + { + "descriptionType": "TechnicalInfo", + "description": "Preview decision: allow_safe_preview; transform: scatter-density-downsample" + } + ] + }, + "schemaOrg": { + "@context": "https://schema.org", + "@type": "Dataset", + "identifier": "10.0000/scibase.fcs.001", + "name": "Microglia cytokine flow panel", + "measurementTechnique": "Flow cytometry standard", + "license": "CC-BY-4.0", + "encodingFormat": "fcs", + "variableMeasured": [ + "FSC", + "IL6" + ], + "additionalProperty": [ + { + "@type": "PropertyValue", + "name": "previewDecision", + "value": "allow_safe_preview" + }, + { + "@type": "PropertyValue", + "name": "previewKind", + "value": "gated_scatter_summary" + }, + { + "@type": "PropertyValue", + "name": "actualChecksum", + "value": "sha256:fcs-original" + } + ] + } + }, + { + "artifactId": "artifact:nd2-003", + "fileName": "organoid-stack.nd2", + "rawType": "nd2", + "previewKind": "channel_thumbnail_contact_sheet", + "decision": "allow_safe_preview", + "severity": "low", + "score": 18, + "reasons": [], + "datacite": { + "identifier": "artifact:nd2-003", + "creators": [ + "SCIBASE Imaging Core" + ], + "titles": [ + { + "title": "Organoid microscopy stack" + } + ], + "publisher": "SCIBASE synthetic repository", + "publicationYear": 2026, + "types": { + "resourceTypeGeneral": "Dataset", + "resourceType": "Microscopy ND2 image stack" + }, + "rightsList": [ + { + "rights": "CC-BY-NC-4.0" + } + ], + "descriptions": [ + { + "descriptionType": "TechnicalInfo", + "description": "Preview decision: allow_safe_preview; transform: thumbnail-contact-sheet" + } + ] + }, + "schemaOrg": { + "@context": "https://schema.org", + "@type": "Dataset", + "identifier": "artifact:nd2-003", + "name": "Organoid microscopy stack", + "measurementTechnique": "Microscopy ND2 image stack", + "license": "CC-BY-NC-4.0", + "encodingFormat": "nd2", + "variableMeasured": [ + "x", + "y", + "intensity" + ], + "additionalProperty": [ + { + "@type": "PropertyValue", + "name": "previewDecision", + "value": "allow_safe_preview" + }, + { + "@type": "PropertyValue", + "name": "previewKind", + "value": "channel_thumbnail_contact_sheet" + }, + { + "@type": "PropertyValue", + "name": "actualChecksum", + "value": "sha256:nd2-stable" + } + ] + } + } + ], + "stats": { + "artifactCount": 4, + "blocked": 1, + "metadataOnly": 1, + "safePreview": 2 + }, + "reviewerQueue": [ + { + "artifactId": "artifact:mzml-002", + "fileName": "metabolomics-run.mzml", + "decision": "block_preview", + "reasons": [ + "raw artifact checksum changed", + "unit metadata drift: intensity", + "calibration drift: mz", + "preview transform would expose raw values", + "preview transform does not record lineage", + "preview transform downsample ratio is too high for safe preview", + "restricted_license_requires_metadata_only_preview", + "embargoed artifact requires metadata-only preview" + ] + }, + { + "artifactId": "artifact:h5-004", + "fileName": "sensor-sweep.h5", + "decision": "metadata_only_preview", + "reasons": [ + "unit metadata drift: impedance", + "calibration drift: impedance" + ] + } + ] +} diff --git a/raw-instrument-preview-integrity-gate/requirements-map.md b/raw-instrument-preview-integrity-gate/requirements-map.md new file mode 100644 index 0000000..b4d34c5 --- /dev/null +++ b/raw-instrument-preview-integrity-gate/requirements-map.md @@ -0,0 +1,21 @@ +# Requirements Map + +## Issue #14: Scientific/Engineering Data & Code Hosting + +| Requirement | Coverage | +| --- | --- | +| Support for raw instrument outputs | `SUPPORTED_RAW_TYPES` classifies `.fcs`, `.mzml`, `.nd2`, and `.h5` artifacts. | +| Metadata-aware previews | `previewDecision` verifies required instrument metadata before generating preview descriptors. | +| Structured metadata standards | `buildDataCitePacket` and `buildSchemaOrgPacket` emit DataCite-style and schema.org-compatible metadata. | +| FAIR reusable metadata | Decisions preserve checksums, licenses, units, calibration, preview policies, and creators. | +| Versioning and diff safety | Checksum and metadata drift block or downgrade previews before researchers inspect stale transformed data. | +| Access and licensing controls | Restricted licenses and embargoed artifacts require metadata-only previews. | +| Executable/reproducible preview transforms | `validatePreviewTransform` requires deterministic, lineage-recording transforms that do not expose raw values. | +| Tests and demo | `test.js` covers safe previews, checksum drift blocks, unit/calibration drift, restricted/embargo behavior, unsupported types, DataCite, and schema.org output. `demo.js` emits JSON and SVG artifacts. | + +## Acceptance Notes + +- Synthetic data only; no external service calls or private data. +- No dependencies beyond the Node.js standard library. +- This is preview-safety logic for raw instrument outputs, not another broad storage manifest. +- Blocked previews preserve reviewer-visible reasons and metadata packets rather than silently failing. diff --git a/raw-instrument-preview-integrity-gate/sample-data.js b/raw-instrument-preview-integrity-gate/sample-data.js new file mode 100644 index 0000000..b28a8f9 --- /dev/null +++ b/raw-instrument-preview-integrity-gate/sample-data.js @@ -0,0 +1,136 @@ +"use strict"; + +module.exports = { + artifacts: [ + { + id: "artifact:fcs-001", + title: "Microglia cytokine flow panel", + fileName: "microglia-panel.fcs", + doi: "10.0000/scibase.fcs.001", + expectedChecksum: "sha256:fcs-original", + actualChecksum: "sha256:fcs-original", + license: "CC-BY-4.0", + previewPolicy: "safe-preview", + creators: ["SCIBASE Flow Core"], + publicationYear: 2026, + expectedMetadata: { + instrumentModel: "Cytek Aurora", + detectorPanel: "IL6/TNF/CD11b", + compensationMatrix: "matrix-v3", + calibration: { laser405: 1.0, laser488: 1.0 }, + units: { FSC: "a.u.", IL6: "a.u." } + }, + observedMetadata: { + instrumentModel: "Cytek Aurora", + detectorPanel: "IL6/TNF/CD11b", + compensationMatrix: "matrix-v3", + calibration: { laser405: 1.01, laser488: 0.99 }, + units: { FSC: "a.u.", IL6: "a.u." } + }, + previewTransform: { + name: "scatter-density-downsample", + deterministic: true, + recordsLineage: true, + requiresRawValueExport: false, + downsampleRatio: 0.1 + } + }, + { + id: "artifact:mzml-002", + title: "Embargoed metabolomics peak run", + fileName: "metabolomics-run.mzml", + doi: "10.0000/scibase.mzml.002", + expectedChecksum: "sha256:mzml-before", + actualChecksum: "sha256:mzml-after", + license: "restricted", + previewPolicy: "safe-preview", + embargoed: true, + creators: ["SCIBASE Metabolomics Core"], + expectedMetadata: { + instrumentModel: "Orbitrap Eclipse", + ionMode: "positive", + mzRange: "50-1500", + calibration: { mz: 1.0 }, + units: { mz: "m/z", intensity: "counts" } + }, + observedMetadata: { + instrumentModel: "Orbitrap Eclipse", + ionMode: "positive", + mzRange: "50-1500", + calibration: { mz: 1.08 }, + units: { mz: "m/z", intensity: "relative" } + }, + previewTransform: { + name: "peak-topline", + deterministic: true, + recordsLineage: false, + requiresRawValueExport: true, + downsampleRatio: 0.5 + } + }, + { + id: "artifact:nd2-003", + title: "Organoid microscopy stack", + fileName: "organoid-stack.nd2", + expectedChecksum: "sha256:nd2-stable", + actualChecksum: "sha256:nd2-stable", + license: "CC-BY-NC-4.0", + previewPolicy: "safe-preview", + creators: ["SCIBASE Imaging Core"], + expectedMetadata: { + instrumentModel: "Nikon AX R", + objective: "40x", + channelMap: ["DAPI", "GFP"], + pixelSizeMicrons: 0.31, + calibration: { pixelSizeMicrons: 0.31 }, + units: { x: "micron", y: "micron", intensity: "a.u." } + }, + observedMetadata: { + instrumentModel: "Nikon AX R", + objective: "40x", + channelMap: ["DAPI", "GFP"], + pixelSizeMicrons: 0.31, + calibration: { pixelSizeMicrons: 0.31 }, + units: { x: "micron", y: "micron", intensity: "a.u." } + }, + previewTransform: { + name: "thumbnail-contact-sheet", + deterministic: true, + recordsLineage: true, + requiresRawValueExport: false, + downsampleRatio: 0.05 + } + }, + { + id: "artifact:h5-004", + title: "Sensor HDF5 sweep bundle", + fileName: "sensor-sweep.h5", + expectedChecksum: "sha256:h5-stable", + actualChecksum: "sha256:h5-stable", + license: "CC0-1.0", + previewPolicy: "safe-preview", + creators: ["SCIBASE Materials Core"], + expectedMetadata: { + instrumentModel: "Keysight E4990A", + schemaVersion: "2.0", + axisMap: { frequency: "Hz", impedance: "Ohm" }, + calibration: { impedance: 1.0 }, + units: { frequency: "Hz", impedance: "Ohm" } + }, + observedMetadata: { + instrumentModel: "Keysight E4990A", + schemaVersion: "2.0", + axisMap: { frequency: "Hz", impedance: "Ohm" }, + calibration: { impedance: 1.03 }, + units: { frequency: "Hz", impedance: "kOhm" } + }, + previewTransform: { + name: "dataset-tree-only", + deterministic: true, + recordsLineage: true, + requiresRawValueExport: false, + downsampleRatio: 0 + } + } + ] +}; diff --git a/raw-instrument-preview-integrity-gate/test.js b/raw-instrument-preview-integrity-gate/test.js new file mode 100644 index 0000000..affb3a2 --- /dev/null +++ b/raw-instrument-preview-integrity-gate/test.js @@ -0,0 +1,62 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + buildPreviewIntegrityGate, + checksumChanged, + metadataDiff, + previewDecision, + unitDrift, + calibrationDrift +} = require("./index"); +const sampleData = require("./sample-data"); + +const report = buildPreviewIntegrityGate(sampleData); + +assert.equal(report.stats.artifactCount, 4); +assert.equal(report.stats.blocked, 1); +assert.equal(report.stats.metadataOnly, 1); +assert.equal(report.stats.safePreview, 2); + +const mzml = report.decisions.find((decision) => decision.artifactId === "artifact:mzml-002"); +assert.equal(mzml.decision, "block_preview"); +assert.equal(mzml.severity, "high"); +assert.ok(mzml.reasons.includes("raw artifact checksum changed")); +assert.ok(mzml.reasons.includes("unit metadata drift: intensity")); +assert.ok(mzml.reasons.includes("calibration drift: mz")); +assert.ok(mzml.reasons.includes("preview transform would expose raw values")); +assert.ok(mzml.reasons.includes("restricted_license_requires_metadata_only_preview")); +assert.ok(mzml.reasons.includes("embargoed artifact requires metadata-only preview")); +assert.equal(mzml.datacite.types.resourceType, "Mass spectrometry mzML"); +assert.equal(mzml.schemaOrg["@context"], "https://schema.org"); + +const fcs = report.decisions.find((decision) => decision.artifactId === "artifact:fcs-001"); +assert.equal(fcs.decision, "allow_safe_preview"); +assert.equal(fcs.previewKind, "gated_scatter_summary"); + +const h5 = report.decisions.find((decision) => decision.artifactId === "artifact:h5-004"); +assert.equal(h5.decision, "metadata_only_preview"); +assert.ok(h5.reasons.includes("unit metadata drift: impedance")); +assert.ok(h5.reasons.includes("calibration drift: impedance")); + +const stableArtifact = sampleData.artifacts[0]; +assert.equal(checksumChanged(stableArtifact), false); +assert.deepEqual(unitDrift(stableArtifact), []); +assert.deepEqual(calibrationDrift(stableArtifact), []); +assert.deepEqual(metadataDiff(stableArtifact), [ + { field: "calibration", expected: { laser405: 1, laser488: 1 }, observed: { laser405: 1.01, laser488: 0.99 } } +]); + +const unsupported = previewDecision({ + id: "artifact:raw-unknown", + title: "Unknown raw bundle", + fileName: "bundle.raw", + expectedChecksum: "sha256:a", + actualChecksum: "sha256:a", + observedMetadata: {}, + previewTransform: { deterministic: true, recordsLineage: true, requiresRawValueExport: false } +}); +assert.equal(unsupported.decision, "block_preview"); +assert.ok(unsupported.reasons[0].includes("unsupported raw instrument type")); + +console.log("raw-instrument-preview-integrity-gate tests passed");