diff --git a/README.md b/README.md index d338cf6..0a98157 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Bounty modules + +- [FAIR Artifact Access Gate](fair-artifact-access-gate/README.md) - validates scientific data and code hosting readiness for SCIBASE issue #14. diff --git a/fair-artifact-access-gate/README.md b/fair-artifact-access-gate/README.md new file mode 100644 index 0000000..c405532 --- /dev/null +++ b/fair-artifact-access-gate/README.md @@ -0,0 +1,28 @@ +# FAIR Artifact Access Gate + +This module is a focused implementation slice for SCIBASE issue #14, "Scientific/Engineering Data & Code Hosting." It models the pre-publication gate a hosted scientific project should pass before SCIBASE exposes datasets, notebooks, source code, figures, raw instrument outputs, and model files to collaborators, reviewers, or the public. + +The gate answers four practical hosting questions: + +- Does the project expose machine-readable metadata through JSON-LD, DataCite, and schema.org? +- Are scientific artifacts cataloged by family, access policy, preview capability, versioning, and deterministic hashes? +- Do the hosted materials satisfy FAIR signals for findability, accessibility, interoperability, and reuse? +- Can SCIBASE produce a reviewer export packet that includes metadata, artifact links, access-policy evidence, and checksums? + +## Files + +- `src/fair-artifact-access-gate.js` contains the deterministic assessment logic. +- `data/sample-artifacts.json` provides a realistic mixed scientific artifact set. +- `test/fair-artifact-access-gate.test.js` covers success and failure paths. +- `scripts/demo.js` prints a concise readiness report for the sample project. +- `docs/demo.svg` and `docs/demo.mp4` show the intended product surface. + +## Run + +```sh +npm test +npm run demo +npm run demo:video +``` + +The implementation is intentionally dependency-free so it can be reviewed without installing a framework or service runtime. diff --git a/fair-artifact-access-gate/data/sample-artifacts.json b/fair-artifact-access-gate/data/sample-artifacts.json new file mode 100644 index 0000000..01d3d32 --- /dev/null +++ b/fair-artifact-access-gate/data/sample-artifacts.json @@ -0,0 +1,107 @@ +{ + "projectId": "lab-astrocyte-calcium-atlas", + "title": "Astrocyte Calcium Imaging Atlas", + "doi": "10.5555/scibase.astrocyte-calcium.2026.v2", + "version": "2.1.0", + "license": "CC-BY-4.0", + "indexed": true, + "publisher": "SCIBASE Demo Repository", + "persistentBaseUrl": "https://scibase.ai/artifacts/lab-astrocyte-calcium-atlas", + "metadata": { + "jsonLd": { + "@context": "https://schema.org", + "@type": "Dataset", + "name": "Astrocyte Calcium Imaging Atlas", + "identifier": "10.5555/scibase.astrocyte-calcium.2026.v2" + }, + "dataCite": { + "identifier": "10.5555/scibase.astrocyte-calcium.2026.v2", + "creators": ["SCIBASE Demo Lab"], + "publisher": "SCIBASE Demo Repository", + "publicationYear": 2026, + "resourceType": "Dataset" + }, + "schemaOrg": { + "name": "Astrocyte Calcium Imaging Atlas", + "description": "Processed calcium imaging events, notebooks, source code, model weights, and raw instrument outputs.", + "license": "https://creativecommons.org/licenses/by/4.0/", + "distribution": "https://scibase.ai/artifacts/lab-astrocyte-calcium-atlas" + } + }, + "tags": { + "keywords": ["calcium imaging", "astrocyte", "time series", "neuroscience"], + "instruments": ["two-photon microscope", "scientific CMOS camera"], + "organisms": ["Mus musculus"], + "variables": ["event amplitude", "event duration", "field of view", "stimulus epoch"] + }, + "artifacts": [ + { + "path": "data/processed/calcium-events.parquet", + "family": "dataset", + "format": "parquet", + "access": "public", + "preview": "table", + "versioned": true, + "diffable": true, + "machineReadable": true, + "sizeBytes": 9420800 + }, + { + "path": "code/notebooks/event-detection.ipynb", + "family": "notebook", + "format": "ipynb", + "access": "public", + "preview": "notebook", + "versioned": true, + "diffable": true, + "machineReadable": true, + "sizeBytes": 184220 + }, + { + "path": "code/src/extract_events.py", + "family": "code", + "format": "python", + "access": "public", + "preview": "syntax-highlight", + "versioned": true, + "diffable": true, + "machineReadable": true, + "sizeBytes": 38291 + }, + { + "path": "supplementary/figures/field-of-view.png", + "family": "figure", + "format": "png", + "access": "public", + "preview": "image", + "versioned": true, + "diffable": false, + "machineReadable": false, + "sizeBytes": 308204 + }, + { + "path": "raw/instrument/session-0423.tiff", + "family": "raw-instrument-output", + "format": "tiff", + "access": "restricted", + "restrictionReason": "contains pre-release animal cohort metadata", + "reviewerAccess": true, + "preview": "thumbnail", + "versioned": true, + "diffable": false, + "machineReadable": false, + "sizeBytes": 182002284 + }, + { + "path": "models/event-classifier.onnx", + "family": "model", + "format": "onnx", + "access": "public", + "preview": "model-card", + "versioned": true, + "diffable": false, + "machineReadable": true, + "sizeBytes": 14082112 + } + ] +} diff --git a/fair-artifact-access-gate/docs/demo.mp4 b/fair-artifact-access-gate/docs/demo.mp4 new file mode 100644 index 0000000..bd8b5d6 Binary files /dev/null and b/fair-artifact-access-gate/docs/demo.mp4 differ diff --git a/fair-artifact-access-gate/docs/demo.svg b/fair-artifact-access-gate/docs/demo.svg new file mode 100644 index 0000000..05bf43b --- /dev/null +++ b/fair-artifact-access-gate/docs/demo.svg @@ -0,0 +1,33 @@ + + FAIR Artifact Access Gate + A SCIBASE scientific data and code hosting readiness dashboard. + + + FAIR Artifact Access Gate + Scientific data and code hosting readiness for SCIBASE issue #14 + + Metadata Standards + JSON-LD + DataCite + schema.org + + FAIR Signals + Findable + Accessible + Reusable + + Access Policy + Public links + Reviewer URLs + Restriction reasons + + Export Packet + Metadata + Manifest + Checksums + + Astrocyte Calcium Imaging Atlas + 6 artifact families: dataset, notebook, code, figure, raw instrument output, model + Ready: standards pass, FAIR pass, restricted reviewer export pass + Deterministic packet hash supports reviewer and collaborator handoff + diff --git a/fair-artifact-access-gate/docs/requirement-map.md b/fair-artifact-access-gate/docs/requirement-map.md new file mode 100644 index 0000000..7026d8f --- /dev/null +++ b/fair-artifact-access-gate/docs/requirement-map.md @@ -0,0 +1,13 @@ +# Requirement Map + +Issue #14 asks for a platform to host scientific and engineering data plus code. This implementation focuses on the acceptance gate that decides whether a hosted project is ready to expose those artifacts. + +| Issue need | Implementation surface | +| --- | --- | +| Store datasets, code, figures, notebooks, raw outputs, and model files | `buildArtifactCatalog` normalizes each artifact family with persistent links, access policy, preview state, versioning, and hashes. | +| Support scientific discovery and citation | `buildMetadataStandardsIndex` checks JSON-LD, DataCite, and schema.org metadata. | +| Keep restricted materials reviewable | `evaluateFairSignals` blocks restricted artifacts without reviewer access and a restriction reason. | +| Produce evidence for maintainers, reviewers, and collaborators | `planReviewerExportPacket` creates a deterministic export manifest with metadata, policy, FAIR report, artifact hashes, and audit digest. | +| Make regressions obvious | `test/fair-artifact-access-gate.test.js` covers the ready path plus metadata, access, and reuse failures. | + +The slice is intentionally narrow: it does not build object storage, upload UI, billing, or auth. It defines the deterministic hosting contract those surfaces can call before publication. diff --git a/fair-artifact-access-gate/package.json b/fair-artifact-access-gate/package.json new file mode 100644 index 0000000..994ece1 --- /dev/null +++ b/fair-artifact-access-gate/package.json @@ -0,0 +1,15 @@ +{ + "name": "fair-artifact-access-gate", + "version": "0.1.0", + "private": true, + "description": "FAIR scientific artifact hosting readiness gate for SCIBASE issue #14.", + "type": "module", + "scripts": { + "test": "node test/fair-artifact-access-gate.test.js", + "demo": "node scripts/demo.js", + "demo:video": "clang -fobjc-arc -framework Foundation -framework AppKit -framework AVFoundation -framework CoreMedia -framework CoreVideo scripts/render-demo-video.m -o /tmp/scibase-fair-artifact-access-demo && /tmp/scibase-fair-artifact-access-demo docs/demo.mp4" + }, + "engines": { + "node": ">=18" + } +} diff --git a/fair-artifact-access-gate/scripts/demo.js b/fair-artifact-access-gate/scripts/demo.js new file mode 100644 index 0000000..d2c77e4 --- /dev/null +++ b/fair-artifact-access-gate/scripts/demo.js @@ -0,0 +1,24 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { assessFairArtifactAccessGate } from "../src/fair-artifact-access-gate.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const samplePath = join(here, "..", "data", "sample-artifacts.json"); +const sample = JSON.parse(await readFile(samplePath, "utf8")); +const report = assessFairArtifactAccessGate(sample); + +console.log(`Project: ${sample.title}`); +console.log(`Ready: ${report.ready ? "yes" : "no"}`); +console.log(`Artifact families: ${report.catalog.families.join(", ")}`); +console.log( + `FAIR: ${report.fairSignals.signals + .map((signal) => `${signal.key}=${signal.ready ? "pass" : "fail"}`) + .join(", ")}` +); +console.log(`Reviewer packet entries: ${report.reviewerPacket.entryCount}`); +console.log(`Reviewer packet hash: ${report.reviewerPacket.packetHash}`); +console.log( + `Audit digest: standards=${report.reviewerPacket.auditDigest.standardsReady}, fair=${report.reviewerPacket.auditDigest.fairReady}, restricted=${report.reviewerPacket.auditDigest.restrictedArtifactCount}` +); +console.log(`Blockers: ${report.blockers.length === 0 ? "none" : report.blockers.join("; ")}`); diff --git a/fair-artifact-access-gate/scripts/render-demo-video.m b/fair-artifact-access-gate/scripts/render-demo-video.m new file mode 100644 index 0000000..830a708 --- /dev/null +++ b/fair-artifact-access-gate/scripts/render-demo-video.m @@ -0,0 +1,156 @@ +#import +#import +#import +#import + +static void FillRounded(NSRect rect, CGFloat radius, NSColor *color) { + NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius]; + [color setFill]; + [path fill]; +} + +static void StrokeRounded(NSRect rect, CGFloat radius, NSColor *color) { + NSBezierPath *path = [NSBezierPath bezierPathWithRoundedRect:rect xRadius:radius yRadius:radius]; + [color setStroke]; + [path setLineWidth:2.0]; + [path stroke]; +} + +static void DrawText(NSString *text, NSRect rect, CGFloat size, NSColor *color, NSFontWeight weight) { + NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; + style.lineBreakMode = NSLineBreakByWordWrapping; + NSDictionary *attrs = @{ + NSFontAttributeName: [NSFont systemFontOfSize:size weight:weight], + NSForegroundColorAttributeName: color, + NSParagraphStyleAttributeName: style + }; + [text drawInRect:rect withAttributes:attrs]; +} + +static NSColor *RGB(CGFloat r, CGFloat g, CGFloat b) { + return [NSColor colorWithCalibratedRed:r / 255.0 green:g / 255.0 blue:b / 255.0 alpha:1.0]; +} + +static void DrawCard(NSString *title, NSArray *lines, NSRect rect, NSColor *fill, NSColor *stroke, NSColor *titleColor, NSColor *bodyColor) { + FillRounded(rect, 14.0, fill); + StrokeRounded(rect, 14.0, stroke); + DrawText(title, NSMakeRect(rect.origin.x + 22, rect.origin.y + rect.size.height - 56, rect.size.width - 44, 30), 22, titleColor, NSFontWeightBold); + for (NSUInteger i = 0; i < lines.count; i++) { + DrawText(lines[i], NSMakeRect(rect.origin.x + 22, rect.origin.y + rect.size.height - 96 - (CGFloat)i * 31, rect.size.width - 44, 28), 18, bodyColor, NSFontWeightRegular); + } +} + +static void DrawFrame(CGContextRef cg, int width, int height, int frame) { + [NSGraphicsContext saveGraphicsState]; + NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithCGContext:cg flipped:NO]; + [NSGraphicsContext setCurrentContext:context]; + + [[NSColor colorWithCalibratedRed:245.0 / 255.0 green:247.0 / 255.0 blue:249.0 / 255.0 alpha:1.0] setFill]; + NSRectFill(NSMakeRect(0, 0, width, height)); + + FillRounded(NSMakeRect(64, 54, 1152, 612), 18, [NSColor whiteColor]); + StrokeRounded(NSMakeRect(64, 54, 1152, 612), 18, RGB(207, 216, 223)); + + CGFloat progress = MIN(1.0, MAX(0.0, ((CGFloat)frame - 8.0) / 64.0)); + DrawText(@"FAIR Artifact Access Gate", NSMakeRect(104, 595, 760, 48), 36, RGB(23, 33, 43), NSFontWeightBold); + DrawText(@"Scientific data and code hosting readiness for SCIBASE issue #14", NSMakeRect(104, 563, 820, 28), 18, RGB(82, 99, 113), NSFontWeightRegular); + + DrawCard(@"Metadata Standards", @[@"JSON-LD", @"DataCite", @"schema.org"], NSMakeRect(104, 362, 252, 152), RGB(233, 246, 240), RGB(159, 208, 186), RGB(23, 72, 49), RGB(39, 99, 74)); + DrawCard(@"FAIR Signals", @[@"Findable", @"Accessible", @"Reusable"], NSMakeRect(386, 362, 252, 152), RGB(237, 242, 251), RGB(171, 192, 231), RGB(32, 60, 105), RGB(49, 85, 143)); + DrawCard(@"Access Policy", @[@"Public links", @"Reviewer URLs", @"Restriction reasons"], NSMakeRect(668, 362, 252, 152), RGB(255, 246, 223), RGB(228, 195, 109), RGB(106, 77, 7), RGB(122, 92, 17)); + DrawCard(@"Export Packet", @[@"Metadata", @"Manifest", @"Checksums"], NSMakeRect(950, 362, 226, 152), RGB(248, 237, 247), RGB(213, 171, 208), RGB(90, 40, 84), RGB(115, 56, 107)); + + FillRounded(NSMakeRect(104, 136, 1072, 174), 14, RGB(23, 33, 43)); + DrawText(@"Astrocyte Calcium Imaging Atlas", NSMakeRect(132, 250, 700, 34), 24, [NSColor whiteColor], NSFontWeightBold); + DrawText(@"6 artifact families: dataset, notebook, code, figure, raw instrument output, model", NSMakeRect(132, 209, 920, 28), 18, RGB(200, 211, 220), NSFontWeightRegular); + DrawText(@"Ready: standards pass, FAIR pass, restricted reviewer export pass", NSMakeRect(132, 172, 820, 28), 18, RGB(200, 211, 220), NSFontWeightRegular); + DrawText(@"Deterministic packet hash supports reviewer and collaborator handoff", NSMakeRect(132, 135, 820, 28), 18, RGB(200, 211, 220), NSFontWeightRegular); + + CGFloat barWidth = 980.0 * progress; + FillRounded(NSMakeRect(132, 93, barWidth, 10), 5, RGB(64, 175, 120)); + DrawText(@"reviewer packet ready", NSMakeRect(132 + barWidth + 14, 84, 220, 28), 14, RGB(82, 99, 113), NSFontWeightMedium); + + [NSGraphicsContext restoreGraphicsState]; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + if (argc < 2) { + fprintf(stderr, "usage: render-demo-video output.mp4\n"); + return 2; + } + + NSString *outputPath = [NSString stringWithUTF8String:argv[1]]; + NSURL *outputURL = [NSURL fileURLWithPath:outputPath]; + [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil]; + + const int width = 1280; + const int height = 720; + NSError *error = nil; + AVAssetWriter *writer = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeMPEG4 error:&error]; + if (!writer) { + NSLog(@"writer error: %@", error); + return 1; + } + + NSDictionary *videoSettings = @{ + AVVideoCodecKey: AVVideoCodecTypeH264, + AVVideoWidthKey: @(width), + AVVideoHeightKey: @(height) + }; + AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings]; + input.expectsMediaDataInRealTime = NO; + + NSDictionary *pixelBufferAttributes = @{ + (NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32ARGB), + (NSString *)kCVPixelBufferWidthKey: @(width), + (NSString *)kCVPixelBufferHeightKey: @(height) + }; + AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input sourcePixelBufferAttributes:pixelBufferAttributes]; + + if (![writer canAddInput:input]) { + NSLog(@"cannot add writer input"); + return 1; + } + [writer addInput:input]; + [writer startWriting]; + [writer startSessionAtSourceTime:kCMTimeZero]; + + CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); + for (int frame = 0; frame < 96; frame++) { + while (!input.readyForMoreMediaData) { + [NSThread sleepForTimeInterval:0.01]; + } + + CVPixelBufferRef buffer = NULL; + CVPixelBufferPoolCreatePixelBuffer(NULL, adaptor.pixelBufferPool, &buffer); + CVPixelBufferLockBaseAddress(buffer, 0); + void *baseAddress = CVPixelBufferGetBaseAddress(buffer); + size_t bytesPerRow = CVPixelBufferGetBytesPerRow(buffer); + CGContextRef cg = CGBitmapContextCreate(baseAddress, width, height, 8, bytesPerRow, colorSpace, kCGImageAlphaNoneSkipFirst); + DrawFrame(cg, width, height, frame); + CGContextRelease(cg); + CVPixelBufferUnlockBaseAddress(buffer, 0); + + CMTime presentationTime = CMTimeMake(frame, 24); + [adaptor appendPixelBuffer:buffer withPresentationTime:presentationTime]; + CVPixelBufferRelease(buffer); + } + + CGColorSpaceRelease(colorSpace); + [input markAsFinished]; + + dispatch_semaphore_t sema = dispatch_semaphore_create(0); + [writer finishWritingWithCompletionHandler:^{ + dispatch_semaphore_signal(sema); + }]; + dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER); + + if (writer.status != AVAssetWriterStatusCompleted) { + NSLog(@"writer failed: %@", writer.error); + return 1; + } + } + + return 0; +} diff --git a/fair-artifact-access-gate/src/fair-artifact-access-gate.js b/fair-artifact-access-gate/src/fair-artifact-access-gate.js new file mode 100644 index 0000000..dea9987 --- /dev/null +++ b/fair-artifact-access-gate/src/fair-artifact-access-gate.js @@ -0,0 +1,331 @@ +import { createHash } from "node:crypto"; + +const SUPPORTED_FAMILIES = new Set([ + "code", + "dataset", + "figure", + "model", + "notebook", + "raw-instrument-output" +]); + +const REQUIRED_TAG_GROUPS = ["keywords", "instruments", "organisms", "variables"]; + +const REQUIRED_METADATA_STANDARDS = [ + { + key: "jsonLd", + label: "JSON-LD", + fields: ["@context", "@type", "name", "identifier"] + }, + { + key: "dataCite", + label: "DataCite", + fields: ["identifier", "creators", "publisher", "publicationYear", "resourceType"] + }, + { + key: "schemaOrg", + label: "schema.org", + fields: ["name", "description", "license", "distribution"] + } +]; + +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 sha256(value) { + return createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function hasValue(value) { + if (Array.isArray(value)) { + return value.length > 0; + } + + return value !== undefined && value !== null && value !== ""; +} + +function projectUrl(project, artifactPath) { + const base = String(project.persistentBaseUrl || "").replace(/\/$/, ""); + const encodedPath = String(artifactPath) + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + + return `${base}/${encodedPath}`; +} + +export function buildMetadataStandardsIndex(project) { + const metadata = project.metadata || {}; + const standards = REQUIRED_METADATA_STANDARDS.map((standard) => { + const payload = metadata[standard.key] || {}; + const missingFields = standard.fields.filter((field) => !hasValue(payload[field])); + + return { + key: standard.key, + label: standard.label, + ready: missingFields.length === 0, + missingFields, + hash: sha256(payload) + }; + }); + + return { + standards, + allReady: standards.every((standard) => standard.ready), + readyStandards: standards.filter((standard) => standard.ready).map((standard) => standard.key), + blockedStandards: standards + .filter((standard) => !standard.ready) + .map((standard) => ({ + key: standard.key, + missingFields: standard.missingFields + })) + }; +} + +export function buildArtifactCatalog(project) { + const blockers = []; + const artifacts = Array.isArray(project.artifacts) ? project.artifacts : []; + const rows = artifacts.map((artifact) => { + const unsupportedFamily = !SUPPORTED_FAMILIES.has(artifact.family); + const restricted = artifact.access === "restricted"; + const row = { + path: artifact.path, + family: artifact.family, + format: artifact.format, + access: artifact.access || "unknown", + preview: artifact.preview || null, + versioned: artifact.versioned === true, + diffable: artifact.diffable === true, + machineReadable: artifact.machineReadable === true, + sizeBytes: Number(artifact.sizeBytes || 0), + persistentUrl: projectUrl(project, artifact.path), + reviewerUrl: restricted ? `${projectUrl(project, artifact.path)}?reviewer=1` : null, + hash: sha256({ + path: artifact.path, + family: artifact.family, + format: artifact.format, + version: project.version, + sizeBytes: artifact.sizeBytes + }) + }; + + if (unsupportedFamily) { + blockers.push(`unsupported artifact family: ${artifact.family || "missing"}`); + } + + if (!artifact.preview) { + blockers.push(`artifact lacks preview: ${artifact.path}`); + } + + if (artifact.versioned !== true) { + blockers.push(`artifact is not versioned: ${artifact.path}`); + } + + if (!["public", "restricted"].includes(row.access)) { + blockers.push(`artifact has invalid access policy: ${artifact.path}`); + } + + if (restricted && artifact.reviewerAccess !== true) { + blockers.push(`restricted artifact lacks reviewer access: ${artifact.path}`); + } + + if (restricted && !artifact.restrictionReason) { + blockers.push(`restricted artifact lacks restriction reason: ${artifact.path}`); + } + + return row; + }); + + const familyCounts = rows.reduce((counts, row) => { + counts[row.family] = (counts[row.family] || 0) + 1; + return counts; + }, {}); + + return { + artifacts: rows, + families: Object.keys(familyCounts).sort(), + familyCounts, + publicCount: rows.filter((row) => row.access === "public").length, + restrictedCount: rows.filter((row) => row.access === "restricted").length, + machineReadableCount: rows.filter((row) => row.machineReadable).length, + versionedCount: rows.filter((row) => row.versioned).length, + diffableCount: rows.filter((row) => row.diffable).length, + totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0), + ready: artifacts.length > 0 && blockers.length === 0, + blockers + }; +} + +export function evaluateFairSignals(project, catalog, standardsIndex) { + const tagBlockers = REQUIRED_TAG_GROUPS + .filter((tagGroup) => !hasValue(project.tags?.[tagGroup])) + .map((tagGroup) => `missing scientific tag group: ${tagGroup}`); + + const signals = [ + { + key: "findable", + ready: + project.indexed === true && + hasValue(project.doi) && + hasValue(project.persistentBaseUrl) && + standardsIndex.readyStandards.includes("dataCite"), + evidence: ["DOI", "persistent URLs", "repository index", "DataCite metadata"] + }, + { + key: "accessible", + ready: catalog.artifacts.every((artifact) => { + if (artifact.access === "public") { + return true; + } + + const original = project.artifacts.find((candidate) => candidate.path === artifact.path) || {}; + return ( + artifact.access === "restricted" && + original.reviewerAccess === true && + hasValue(original.restrictionReason) + ); + }), + evidence: ["public links", "restricted-access reasons", "reviewer access URLs"] + }, + { + key: "interoperable", + ready: + standardsIndex.readyStandards.includes("jsonLd") && + standardsIndex.readyStandards.includes("schemaOrg") && + catalog.artifacts + .filter((artifact) => artifact.machineReadable) + .every((artifact) => hasValue(artifact.format)), + evidence: ["JSON-LD", "schema.org", "machine-readable formats"] + }, + { + key: "reusable", + ready: + hasValue(project.license) && + hasValue(project.version) && + tagBlockers.length === 0 && + catalog.artifacts.every((artifact) => artifact.versioned), + evidence: ["license", "version", "scientific tags", "artifact versioning"] + } + ]; + + const blockers = [ + ...tagBlockers, + ...signals + .filter((signal) => !signal.ready) + .map((signal) => `FAIR signal failed: ${signal.key}`) + ]; + + return { + signals, + allReady: signals.every((signal) => signal.ready), + blockers + }; +} + +export function planReviewerExportPacket(project, catalog, standardsIndex, fairSignals) { + const metadataEntries = [ + { + path: "metadata/standards-index.json", + kind: "metadata", + hash: sha256(standardsIndex) + }, + { + path: "reports/fair-signals.json", + kind: "report", + hash: sha256(fairSignals) + }, + { + path: "policies/access-policy.json", + kind: "policy", + hash: sha256({ + restricted: project.artifacts + .filter((artifact) => artifact.access === "restricted") + .map((artifact) => ({ + path: artifact.path, + reason: artifact.restrictionReason, + reviewerAccess: artifact.reviewerAccess === true + })) + }) + }, + { + path: "manifests/artifact-catalog.json", + kind: "manifest", + hash: sha256(catalog) + } + ]; + + const artifactEntries = catalog.artifacts.map((artifact) => ({ + path: artifact.path, + kind: artifact.family, + access: artifact.access, + persistentUrl: artifact.persistentUrl, + reviewerUrl: artifact.reviewerUrl, + hash: artifact.hash + })); + + const entries = [...metadataEntries, ...artifactEntries]; + const packetHash = sha256({ + projectId: project.projectId, + version: project.version, + entries + }); + + return { + projectId: project.projectId, + title: project.title, + version: project.version, + entryCount: entries.length, + entries, + packetHash, + auditDigest: { + projectId: project.projectId, + standardsReady: standardsIndex.allReady, + fairReady: fairSignals.allReady, + restrictedArtifactCount: catalog.restrictedCount, + packetHash + } + }; +} + +export function assessFairArtifactAccessGate(project) { + const standardsIndex = buildMetadataStandardsIndex(project); + const catalog = buildArtifactCatalog(project); + const fairSignals = evaluateFairSignals(project, catalog, standardsIndex); + const reviewerPacket = planReviewerExportPacket(project, catalog, standardsIndex, fairSignals); + + const metadataBlockers = standardsIndex.blockedStandards.map( + (standard) => `metadata standard not ready: ${standard.key}` + ); + + const blockers = [ + ...metadataBlockers, + ...catalog.blockers, + ...fairSignals.blockers + ]; + + return { + projectId: project.projectId, + ready: + standardsIndex.allReady && + catalog.ready && + fairSignals.allReady && + blockers.length === 0, + standardsIndex, + catalog, + fairSignals, + reviewerPacket, + blockers + }; +} diff --git a/fair-artifact-access-gate/test/fair-artifact-access-gate.test.js b/fair-artifact-access-gate/test/fair-artifact-access-gate.test.js new file mode 100644 index 0000000..e0c4a28 --- /dev/null +++ b/fair-artifact-access-gate/test/fair-artifact-access-gate.test.js @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { assessFairArtifactAccessGate } from "../src/fair-artifact-access-gate.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const samplePath = join(here, "..", "data", "sample-artifacts.json"); +const sample = JSON.parse(await readFile(samplePath, "utf8")); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +{ + const report = assessFairArtifactAccessGate(sample); + assert.equal(report.ready, true); + assert.equal(report.blockers.length, 0); + assert.deepEqual(report.catalog.families, [ + "code", + "dataset", + "figure", + "model", + "notebook", + "raw-instrument-output" + ]); + assert.equal(report.standardsIndex.allReady, true); + assert.equal(report.fairSignals.allReady, true); + assert.equal(report.reviewerPacket.entryCount, sample.artifacts.length + 4); + assert.equal(report.reviewerPacket.auditDigest.packetHash, report.reviewerPacket.packetHash); +} + +{ + const project = clone(sample); + delete project.metadata.dataCite.publisher; + const report = assessFairArtifactAccessGate(project); + assert.equal(report.ready, false); + assert.ok(report.blockers.includes("metadata standard not ready: dataCite")); + assert.ok(report.blockers.includes("FAIR signal failed: findable")); +} + +{ + const project = clone(sample); + const rawArtifact = project.artifacts.find( + (artifact) => artifact.family === "raw-instrument-output" + ); + rawArtifact.reviewerAccess = false; + delete rawArtifact.restrictionReason; + const report = assessFairArtifactAccessGate(project); + assert.equal(report.ready, false); + assert.ok( + report.blockers.includes( + "restricted artifact lacks reviewer access: raw/instrument/session-0423.tiff" + ) + ); + assert.ok( + report.blockers.includes( + "restricted artifact lacks restriction reason: raw/instrument/session-0423.tiff" + ) + ); + assert.ok(report.blockers.includes("FAIR signal failed: accessible")); +} + +{ + const project = clone(sample); + delete project.tags.variables; + const report = assessFairArtifactAccessGate(project); + assert.equal(report.ready, false); + assert.ok(report.blockers.includes("missing scientific tag group: variables")); + assert.ok(report.blockers.includes("FAIR signal failed: reusable")); +}