From d99d49bef8a28574a702acbd060044645ded4b57 Mon Sep 17 00:00:00 2001 From: Telemark Codex Agent Date: Sun, 16 Aug 2026 00:14:07 +0200 Subject: [PATCH] Add dataset split release guard --- dataset-split-release-guard/README.md | 44 +++ .../data/sample_split_release_packets.json | 214 ++++++++++++ dataset-split-release-guard/package.json | 13 + dataset-split-release-guard/reports/demo.mp4 | Bin 0 -> 51812 bytes .../reports/reviewer-packet.md | 64 ++++ .../reports/summary.json | 317 ++++++++++++++++++ .../reports/summary.svg | 17 + dataset-split-release-guard/scripts/demo.js | 77 +++++ .../scripts/render-demo-video.js | 60 ++++ dataset-split-release-guard/src/index.js | 275 +++++++++++++++ .../test/dataset-split-release-guard.test.js | 56 ++++ 11 files changed, 1137 insertions(+) create mode 100644 dataset-split-release-guard/README.md create mode 100644 dataset-split-release-guard/data/sample_split_release_packets.json create mode 100644 dataset-split-release-guard/package.json create mode 100644 dataset-split-release-guard/reports/demo.mp4 create mode 100644 dataset-split-release-guard/reports/reviewer-packet.md create mode 100644 dataset-split-release-guard/reports/summary.json create mode 100644 dataset-split-release-guard/reports/summary.svg create mode 100644 dataset-split-release-guard/scripts/demo.js create mode 100644 dataset-split-release-guard/scripts/render-demo-video.js create mode 100644 dataset-split-release-guard/src/index.js create mode 100644 dataset-split-release-guard/test/dataset-split-release-guard.test.js diff --git a/dataset-split-release-guard/README.md b/dataset-split-release-guard/README.md new file mode 100644 index 00000000..7df3b1a2 --- /dev/null +++ b/dataset-split-release-guard/README.md @@ -0,0 +1,44 @@ +# Dataset Split Release Guard + +This contribution adds a focused release guard for train/validation/test split manifests in SCIBASE's Scientific/Engineering Data & Code Hosting layer. It checks whether hosted dataset split artifacts are safe before DOI publication, API release, public previews, or reproducibility badges. + +The slice is intentionally narrow. It does not rebuild broad FAIR manifests, artifact package integrity, schema evolution, data dictionaries, malware quarantine, sandbox egress, notebook rerun determinism, spreadsheet formula provenance, accessibility previews, SBOM checks, or general license metadata. It covers the release boundary for split artifacts. + +## What It Checks + +- Required train, validation, and test split manifests. +- Dataset record count parity with split record totals. +- Record identifier overlap across splits. +- Group-level leakage across participant, patient, subject, lab, or other grouping identifiers. +- Deterministic seed and split-script hash provenance. +- Per-split checksum and manifest-hash coverage. +- License and access-state parity across split artifacts. +- Class or stratum balance drift beyond release policy. +- Embargoed records present in public split manifests. +- Public DOI/API release left enabled while split-integrity findings are unresolved. + +## Running Locally + +```bash +npm test +npm run demo +npm run check +npm run video +``` + +The demo uses only synthetic split release packets in `data/sample_split_release_packets.json`. + +Generated files: + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.mp4` + +The MP4 is generated from an FFmpeg color source and drawtext slate. It is not a screen recording and contains no desktop capture or private information. + +## Why This Matters + +Data/code hosting is not only about storing files. A hosted dataset often becomes the basis for reproduced results, model comparisons, public APIs, and DOI-backed artifacts. If train, validation, and test manifests leak records or participant groups across boundaries, downstream analyses can look reproducible while being contaminated. + +This guard gives SCIBASE a deterministic packet to hold risky releases, regenerate split manifests, preserve checksum evidence, and keep public releases frozen until split artifacts are safe. diff --git a/dataset-split-release-guard/data/sample_split_release_packets.json b/dataset-split-release-guard/data/sample_split_release_packets.json new file mode 100644 index 00000000..a12554e1 --- /dev/null +++ b/dataset-split-release-guard/data/sample_split_release_packets.json @@ -0,0 +1,214 @@ +[ + { + "id": "split-release-001", + "title": "Clean imaging dataset split package for DOI release", + "dataset": { + "recordCount": 9, + "expectedClassBalance": { "case": 0.33, "control": 0.67 } + }, + "splits": { + "train": { + "recordCount": 5, + "recordIds": ["r1", "r2", "r3", "r4", "r5"], + "groupIds": ["p1", "p2", "p3", "p4", "p5"], + "classBalance": { "case": 0.4, "control": 0.6 }, + "license": "CC-BY-4.0", + "access": "public", + "checksum": "sha256:train", + "manifestHash": "sha256:manifest-train", + "embargoedRecordIds": [] + }, + "validation": { + "recordCount": 2, + "recordIds": ["r6", "r7"], + "groupIds": ["p6", "p7"], + "classBalance": { "case": 0.5, "control": 0.5 }, + "license": "CC-BY-4.0", + "access": "public", + "checksum": "sha256:validation", + "manifestHash": "sha256:manifest-validation", + "embargoedRecordIds": [] + }, + "test": { + "recordCount": 2, + "recordIds": ["r8", "r9"], + "groupIds": ["p8", "p9"], + "classBalance": { "case": 0.5, "control": 0.5 }, + "license": "CC-BY-4.0", + "access": "public", + "checksum": "sha256:test", + "manifestHash": "sha256:manifest-test", + "embargoedRecordIds": [] + } + }, + "provenance": { + "seedLocked": true, + "splitScriptHashPresent": true + }, + "release": { + "publicDoiRelease": true, + "freezePublicRelease": false + }, + "policy": { + "requireGroupDisjointness": true, + "maxClassBalanceDelta": 0.2 + } + }, + { + "id": "split-hold-002", + "title": "Clinical split package with patient leakage and embargoed rows", + "dataset": { + "recordCount": 8, + "expectedClassBalance": { "case": 0.5, "control": 0.5 } + }, + "splits": { + "train": { + "recordCount": 4, + "recordIds": ["c1", "c2", "c3", "c4"], + "groupIds": ["patient-1", "patient-2", "patient-3", "patient-4"], + "classBalance": { "case": 0.75, "control": 0.25 }, + "license": "restricted-clinical", + "access": "private", + "checksum": "sha256:train", + "manifestHash": "sha256:manifest-train", + "embargoedRecordIds": [] + }, + "validation": { + "recordCount": 2, + "recordIds": ["c4", "c5"], + "groupIds": ["patient-4", "patient-5"], + "classBalance": { "case": 1, + "control": 0 }, + "license": "restricted-clinical", + "access": "public", + "checksum": "", + "manifestHash": "", + "embargoedRecordIds": ["c5"] + }, + "test": { + "recordCount": 2, + "recordIds": ["c6", "c7"], + "groupIds": ["patient-6", "patient-7"], + "classBalance": { "case": 0, + "control": 1 }, + "license": "restricted-clinical", + "access": "private", + "checksum": "sha256:test", + "manifestHash": "sha256:manifest-test", + "embargoedRecordIds": [] + } + }, + "provenance": { + "seedLocked": false, + "splitScriptHashPresent": false + }, + "release": { + "publicDoiRelease": true, + "freezePublicRelease": false + }, + "policy": { + "requireGroupDisjointness": true, + "maxClassBalanceDelta": 0.15 + } + }, + { + "id": "split-review-003", + "title": "Good disjointness with missing provenance and balance drift", + "dataset": { + "recordCount": 6, + "expectedClassBalance": { "positive": 0.5, "negative": 0.5 } + }, + "splits": { + "train": { + "recordCount": 3, + "recordIds": ["a1", "a2", "a3"], + "groupIds": ["g1", "g2", "g3"], + "classBalance": { "positive": 0.9, "negative": 0.1 }, + "license": "CC0-1.0", + "access": "public", + "checksum": "sha256:train", + "manifestHash": "sha256:manifest-train", + "embargoedRecordIds": [] + }, + "validation": { + "recordCount": 1, + "recordIds": ["a4"], + "groupIds": ["g4"], + "classBalance": { "positive": 0, + "negative": 1 }, + "license": "CC0-1.0", + "access": "public", + "checksum": "sha256:validation", + "manifestHash": "sha256:manifest-validation", + "embargoedRecordIds": [] + }, + "test": { + "recordCount": 2, + "recordIds": ["a5", "a6"], + "groupIds": ["g5", "g6"], + "classBalance": { "positive": 0.5, "negative": 0.5 }, + "license": "CC0-1.0", + "access": "public", + "checksum": "sha256:test", + "manifestHash": "sha256:manifest-test", + "embargoedRecordIds": [] + } + }, + "provenance": { + "seedLocked": true, + "splitScriptHashPresent": false + }, + "release": { + "publicDoiRelease": false, + "freezePublicRelease": true + }, + "policy": { + "requireGroupDisjointness": true, + "maxClassBalanceDelta": 0.25 + } + }, + { + "id": "split-hold-004", + "title": "Missing test split with public release still enabled", + "dataset": { + "recordCount": 5, + "expectedClassBalance": { "included": 1 } + }, + "splits": { + "train": { + "recordCount": 3, + "recordIds": ["m1", "m2", "m3"], + "groupIds": ["mg1", "mg2", "mg3"], + "classBalance": { "included": 1 }, + "license": "CC-BY-4.0", + "access": "public", + "checksum": "sha256:train", + "manifestHash": "sha256:manifest-train", + "embargoedRecordIds": [] + }, + "validation": { + "recordCount": 2, + "recordIds": ["m4", "m5"], + "groupIds": ["mg4", "mg5"], + "classBalance": { "included": 1 }, + "license": "CC-BY-4.0", + "access": "public", + "checksum": "sha256:validation", + "manifestHash": "sha256:manifest-validation", + "embargoedRecordIds": [] + } + }, + "provenance": { + "seedLocked": true, + "splitScriptHashPresent": true + }, + "release": { + "publicDoiRelease": true, + "freezePublicRelease": false + }, + "policy": { + "requireGroupDisjointness": true, + "maxClassBalanceDelta": 0.15 + } + } +] diff --git a/dataset-split-release-guard/package.json b/dataset-split-release-guard/package.json new file mode 100644 index 00000000..d3562a30 --- /dev/null +++ b/dataset-split-release-guard/package.json @@ -0,0 +1,13 @@ +{ + "name": "dataset-split-release-guard", + "version": "1.0.0", + "description": "Dependency-free release guard for hosted train/validation/test split manifests.", + "main": "src/index.js", + "scripts": { + "test": "node --test", + "demo": "node scripts/demo.js", + "video": "node scripts/render-demo-video.js", + "check": "node --check src/index.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js" + }, + "license": "MIT" +} diff --git a/dataset-split-release-guard/reports/demo.mp4 b/dataset-split-release-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..7574a7c2da42749ecb51df89bbb7e385a74644d0 GIT binary patch literal 51812 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
)j)Ne`umk>iP&_DlIkcwqbeK+SQ%d8l>MRST(V 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%0AynPGg3)=xVDRp^CpLNkRz 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 literal 0 HcmV?d00001 diff --git a/dataset-split-release-guard/reports/reviewer-packet.md b/dataset-split-release-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..9b3b6682 --- /dev/null +++ b/dataset-split-release-guard/reports/reviewer-packet.md @@ -0,0 +1,64 @@ +# Dataset Split Release Guard Report + +Generated: 2026-08-15T22:12:47.311Z +Packets analyzed: 4 +Decision counts: RELEASE 1, REVIEW_RELEASE 1, HOLD_RELEASE 2 + +## Findings + +### split-release-001: Clean imaging dataset split package for DOI release + +Decision: RELEASE +Splits: train, validation, test + +- No split release risk detected. + +Recommended actions: +- Release split manifests with normal artifact provenance. + +### split-hold-002: Clinical split package with patient leakage and embargoed rows + +Decision: HOLD_RELEASE +Splits: train, validation, test + +- CRITICAL TRAIN_VALIDATION_TEST_RECORD_OVERLAP: One or more records appear in multiple splits. +- CRITICAL GROUP_LEAKAGE_ACROSS_SPLITS: A participant, patient, subject, lab, or other grouping identifier appears across multiple splits. +- MAJOR SPLIT_PROVENANCE_INCOMPLETE: The split generation seed or script hash is missing. +- MAJOR SPLIT_CHECKSUM_COVERAGE_MISSING: One or more split manifests lack checksum or manifest hash evidence. +- MAJOR SPLIT_LICENSE_ACCESS_PARITY_MISMATCH: Split manifests have inconsistent license or access states. +- MAJOR STRATIFICATION_BALANCE_DRIFT: Class or stratum balance differs from the dataset policy beyond the allowed threshold. +- CRITICAL EMBARGOED_RECORD_IN_PUBLIC_SPLIT: Embargoed records are present in one or more release split manifests. +- CRITICAL PUBLIC_RELEASE_NOT_FROZEN_FOR_SPLIT_RISK: Public DOI/API release is enabled while split-integrity findings remain unresolved. + +Recommended actions: +- Freeze public DOI/API release and reproducibility badges. +- Attach findings to the dataset release review packet. +- Regenerate split manifests or document curator-approved exceptions. +- Re-run checksum, license, access, and stratification checks before DOI/API release. + +### split-review-003: Good disjointness with missing provenance and balance drift + +Decision: REVIEW_RELEASE +Splits: train, validation, test + +- MAJOR SPLIT_PROVENANCE_INCOMPLETE: The split generation seed or script hash is missing. +- MAJOR STRATIFICATION_BALANCE_DRIFT: Class or stratum balance differs from the dataset policy beyond the allowed threshold. + +Recommended actions: +- Attach findings to the dataset release review packet. +- Regenerate split manifests or document curator-approved exceptions. +- Re-run checksum, license, access, and stratification checks before DOI/API release. + +### split-hold-004: Missing test split with public release still enabled + +Decision: HOLD_RELEASE +Splits: train, validation + +- CRITICAL REQUIRED_SPLIT_MISSING: The release packet lacks one or more required train/validation/test split manifests. +- CRITICAL PUBLIC_RELEASE_NOT_FROZEN_FOR_SPLIT_RISK: Public DOI/API release is enabled while split-integrity findings remain unresolved. + +Recommended actions: +- Freeze public DOI/API release and reproducibility badges. +- Attach findings to the dataset release review packet. +- Regenerate split manifests or document curator-approved exceptions. +- Re-run checksum, license, access, and stratification checks before DOI/API release. diff --git a/dataset-split-release-guard/reports/summary.json b/dataset-split-release-guard/reports/summary.json new file mode 100644 index 00000000..df142cf1 --- /dev/null +++ b/dataset-split-release-guard/reports/summary.json @@ -0,0 +1,317 @@ +{ + "generatedAt": "2026-08-15T22:12:47.311Z", + "totalPackets": 4, + "counts": { + "RELEASE": 1, + "REVIEW_RELEASE": 1, + "HOLD_RELEASE": 2 + }, + "results": [ + { + "id": "split-release-001", + "title": "Clean imaging dataset split package for DOI release", + "decision": "RELEASE", + "splitNames": [ + "train", + "validation", + "test" + ], + "findings": [], + "recommendedActions": [ + "Release split manifests with normal artifact provenance." + ] + }, + { + "id": "split-hold-002", + "title": "Clinical split package with patient leakage and embargoed rows", + "decision": "HOLD_RELEASE", + "splitNames": [ + "train", + "validation", + "test" + ], + "findings": [ + { + "code": "TRAIN_VALIDATION_TEST_RECORD_OVERLAP", + "severity": "critical", + "message": "One or more records appear in multiple splits.", + "evidence": { + "overlaps": [ + { + "left": "train", + "right": "validation", + "key": "recordIds", + "values": [ + "c4" + ] + } + ] + }, + "remediation": "Regenerate split manifests with disjoint record identifiers and new manifest hashes." + }, + { + "code": "GROUP_LEAKAGE_ACROSS_SPLITS", + "severity": "critical", + "message": "A participant, patient, subject, lab, or other grouping identifier appears across multiple splits.", + "evidence": { + "overlaps": [ + { + "left": "train", + "right": "validation", + "key": "groupIds", + "values": [ + "patient-4" + ] + } + ] + }, + "remediation": "Regenerate splits with group-level disjointness to prevent leakage across train/validation/test boundaries." + }, + { + "code": "SPLIT_PROVENANCE_INCOMPLETE", + "severity": "major", + "message": "The split generation seed or script hash is missing.", + "evidence": { + "seedLocked": false, + "splitScriptHashPresent": false + }, + "remediation": "Attach deterministic seed and split-script hash evidence before DOI/API release." + }, + { + "code": "SPLIT_CHECKSUM_COVERAGE_MISSING", + "severity": "major", + "message": "One or more split manifests lack checksum or manifest hash evidence.", + "evidence": { + "splits": [ + "train", + "validation", + "test" + ] + }, + "remediation": "Add per-split content checksums and manifest hashes before release." + }, + { + "code": "SPLIT_LICENSE_ACCESS_PARITY_MISMATCH", + "severity": "major", + "message": "Split manifests have inconsistent license or access states.", + "evidence": { + "licenses": [ + { + "split": "train", + "license": "restricted-clinical" + }, + { + "split": "validation", + "license": "restricted-clinical" + }, + { + "split": "test", + "license": "restricted-clinical" + } + ], + "access": [ + { + "split": "train", + "access": "private" + }, + { + "split": "validation", + "access": "public" + }, + { + "split": "test", + "access": "private" + } + ] + }, + "remediation": "Align license and access state across splits or document why a split must remain restricted." + }, + { + "code": "STRATIFICATION_BALANCE_DRIFT", + "severity": "major", + "message": "Class or stratum balance differs from the dataset policy beyond the allowed threshold.", + "evidence": { + "drifts": [ + { + "split": "train", + "label": "case", + "actual": 0.75, + "expected": 0.5, + "delta": 0.25 + }, + { + "split": "train", + "label": "control", + "actual": 0.25, + "expected": 0.5, + "delta": 0.25 + }, + { + "split": "validation", + "label": "case", + "actual": 1, + "expected": 0.5, + "delta": 0.5 + }, + { + "split": "validation", + "label": "control", + "actual": 0, + "expected": 0.5, + "delta": 0.5 + }, + { + "split": "test", + "label": "case", + "actual": 0, + "expected": 0.5, + "delta": 0.5 + }, + { + "split": "test", + "label": "control", + "actual": 1, + "expected": 0.5, + "delta": 0.5 + } + ] + }, + "remediation": "Regenerate stratified splits or document the imbalance in the release notes." + }, + { + "code": "EMBARGOED_RECORD_IN_PUBLIC_SPLIT", + "severity": "critical", + "message": "Embargoed records are present in one or more release split manifests.", + "evidence": { + "embargoedRecords": [ + { + "split": "validation", + "recordId": "c5" + } + ] + }, + "remediation": "Remove embargoed records or keep the affected split private until embargo restrictions are lifted." + }, + { + "code": "PUBLIC_RELEASE_NOT_FROZEN_FOR_SPLIT_RISK", + "severity": "critical", + "message": "Public DOI/API release is enabled while split-integrity findings remain unresolved.", + "evidence": { + "publicDoiRelease": true, + "freezePublicRelease": false + }, + "remediation": "Freeze DOI/API/public-preview release until split integrity issues are resolved." + } + ], + "recommendedActions": [ + "Freeze public DOI/API release and reproducibility badges.", + "Attach findings to the dataset release review packet.", + "Regenerate split manifests or document curator-approved exceptions.", + "Re-run checksum, license, access, and stratification checks before DOI/API release." + ] + }, + { + "id": "split-review-003", + "title": "Good disjointness with missing provenance and balance drift", + "decision": "REVIEW_RELEASE", + "splitNames": [ + "train", + "validation", + "test" + ], + "findings": [ + { + "code": "SPLIT_PROVENANCE_INCOMPLETE", + "severity": "major", + "message": "The split generation seed or script hash is missing.", + "evidence": { + "seedLocked": true, + "splitScriptHashPresent": false + }, + "remediation": "Attach deterministic seed and split-script hash evidence before DOI/API release." + }, + { + "code": "STRATIFICATION_BALANCE_DRIFT", + "severity": "major", + "message": "Class or stratum balance differs from the dataset policy beyond the allowed threshold.", + "evidence": { + "drifts": [ + { + "split": "train", + "label": "positive", + "actual": 0.9, + "expected": 0.5, + "delta": 0.4 + }, + { + "split": "train", + "label": "negative", + "actual": 0.1, + "expected": 0.5, + "delta": 0.4 + }, + { + "split": "validation", + "label": "positive", + "actual": 0, + "expected": 0.5, + "delta": 0.5 + }, + { + "split": "validation", + "label": "negative", + "actual": 1, + "expected": 0.5, + "delta": 0.5 + } + ] + }, + "remediation": "Regenerate stratified splits or document the imbalance in the release notes." + } + ], + "recommendedActions": [ + "Attach findings to the dataset release review packet.", + "Regenerate split manifests or document curator-approved exceptions.", + "Re-run checksum, license, access, and stratification checks before DOI/API release." + ] + }, + { + "id": "split-hold-004", + "title": "Missing test split with public release still enabled", + "decision": "HOLD_RELEASE", + "splitNames": [ + "train", + "validation" + ], + "findings": [ + { + "code": "REQUIRED_SPLIT_MISSING", + "severity": "critical", + "message": "The release packet lacks one or more required train/validation/test split manifests.", + "evidence": { + "missingSplits": [ + "test" + ] + }, + "remediation": "Hold release until all required split manifests are present and checksummed." + }, + { + "code": "PUBLIC_RELEASE_NOT_FROZEN_FOR_SPLIT_RISK", + "severity": "critical", + "message": "Public DOI/API release is enabled while split-integrity findings remain unresolved.", + "evidence": { + "publicDoiRelease": true, + "freezePublicRelease": false + }, + "remediation": "Freeze DOI/API/public-preview release until split integrity issues are resolved." + } + ], + "recommendedActions": [ + "Freeze public DOI/API release and reproducibility badges.", + "Attach findings to the dataset release review packet.", + "Regenerate split manifests or document curator-approved exceptions.", + "Re-run checksum, license, access, and stratification checks before DOI/API release." + ] + } + ] +} diff --git a/dataset-split-release-guard/reports/summary.svg b/dataset-split-release-guard/reports/summary.svg new file mode 100644 index 00000000..327ebfd7 --- /dev/null +++ b/dataset-split-release-guard/reports/summary.svg @@ -0,0 +1,17 @@ + + + Dataset Split Release Guard + Synthetic train/validation/test manifest audit before public hosting release. + Decision distribution + HOLD + + 2 + REVIEW + + 1 + RELEASE + + 1 + Checks missing splits, record/group leakage, checksum coverage, seed provenance, access parity, embargo rows, and balance drift. + Synthetic generated artifact only. No desktop capture or private data. + diff --git a/dataset-split-release-guard/scripts/demo.js b/dataset-split-release-guard/scripts/demo.js new file mode 100644 index 00000000..3df0b3d1 --- /dev/null +++ b/dataset-split-release-guard/scripts/demo.js @@ -0,0 +1,77 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzeSplitReleasePackets } = require("../src"); + +const root = path.resolve(__dirname, ".."); +const dataPath = path.join(root, "data", "sample_split_release_packets.json"); +const outDir = path.join(root, "reports"); +const packets = JSON.parse(fs.readFileSync(dataPath, "utf8")); +const report = analyzeSplitReleasePackets(packets); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`); + +const markdown = [ + "# Dataset Split Release Guard Report", + "", + `Generated: ${report.generatedAt}`, + `Packets analyzed: ${report.totalPackets}`, + `Decision counts: RELEASE ${report.counts.RELEASE}, REVIEW_RELEASE ${report.counts.REVIEW_RELEASE}, HOLD_RELEASE ${report.counts.HOLD_RELEASE}`, + "", + "## Findings", + "", + ...report.results.flatMap((item) => [ + `### ${item.id}: ${item.title}`, + "", + `Decision: ${item.decision}`, + `Splits: ${item.splitNames.join(", ")}`, + "", + item.findings.length === 0 + ? "- No split release risk detected." + : item.findings.map((finding) => `- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`).join("\n"), + "", + "Recommended actions:", + ...item.recommendedActions.map((action) => `- ${action}`), + "" + ]) +].join("\n"); + +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), markdown); + +const total = Math.max(1, report.totalPackets); +const maxBar = 620; +const holdWidth = Math.round((report.counts.HOLD_RELEASE / total) * maxBar); +const reviewWidth = Math.round((report.counts.REVIEW_RELEASE / total) * maxBar); +const releaseWidth = Math.round((report.counts.RELEASE / total) * maxBar); +const svg = ` + + Dataset Split Release Guard + Synthetic train/validation/test manifest audit before public hosting release. + Decision distribution + HOLD + + ${report.counts.HOLD_RELEASE} + REVIEW + + ${report.counts.REVIEW_RELEASE} + RELEASE + + ${report.counts.RELEASE} + Checks missing splits, record/group leakage, checksum coverage, seed provenance, access parity, embargo rows, and balance drift. + Synthetic generated artifact only. No desktop capture or private data. + +`; + +fs.writeFileSync(path.join(outDir, "summary.svg"), svg); + +console.log(JSON.stringify({ + packets: report.totalPackets, + counts: report.counts, + outputs: [ + path.join(outDir, "summary.json"), + path.join(outDir, "reviewer-packet.md"), + path.join(outDir, "summary.svg") + ] +}, null, 2)); diff --git a/dataset-split-release-guard/scripts/render-demo-video.js b/dataset-split-release-guard/scripts/render-demo-video.js new file mode 100644 index 00000000..93702374 --- /dev/null +++ b/dataset-split-release-guard/scripts/render-demo-video.js @@ -0,0 +1,60 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const ffmpeg = path.resolve( + root, + "..", + "..", + "tool_downloads", + "video_tools", + "node_modules", + "ffmpeg-static", + "ffmpeg.exe" +); +const outDir = path.join(root, "reports"); +const out = path.join(outDir, "demo.mp4"); +const font = "C\\:/Windows/Fonts/arial.ttf"; + +fs.mkdirSync(outDir, { recursive: true }); + +const draw = [ + `drawtext=fontfile=${font}:text='Dataset Split Release Guard':x=64:y=56:fontsize=42:fontcolor=black`, + `drawtext=fontfile=${font}:text='Train validation test manifest audit before public hosting release':x=64:y=122:fontsize=25:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic split packets analyzed 4':x=64:y=214:fontsize=34:fontcolor=black`, + `drawtext=fontfile=${font}:text='HOLD 2 REVIEW 1 RELEASE 1':x=64:y=274:fontsize=40:fontcolor=0x245fd6`, + `drawtext=fontfile=${font}:text='Detects record overlap group leakage embargo rows and checksum gaps':x=64:y=366:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Freezes DOI API release until split artifacts are safe':x=64:y=428:fontsize=26:fontcolor=0x34413b`, + `drawtext=fontfile=${font}:text='Synthetic generated slate only no desktop capture or private data':x=64:y=492:fontsize=26:fontcolor=0x34413b` +].join(","); + +if (!fs.existsSync(ffmpeg)) { + throw new Error(`ffmpeg binary not found at ${ffmpeg}`); +} + +const result = spawnSync(ffmpeg, [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0xf9faf8:s=1280x720:d=8:r=30", + "-vf", + draw, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + out +], { encoding: "utf8" }); + +if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status || 1); +} + +console.log(JSON.stringify({ out, bytes: fs.statSync(out).size }, null, 2)); diff --git a/dataset-split-release-guard/src/index.js b/dataset-split-release-guard/src/index.js new file mode 100644 index 00000000..7f2b9bec --- /dev/null +++ b/dataset-split-release-guard/src/index.js @@ -0,0 +1,275 @@ +"use strict"; + +const REQUIRED_SPLITS = ["train", "validation", "test"]; + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function normalizeText(value) { + return String(value || "").trim().toLowerCase(); +} + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function normalizePacket(packet) { + return { + id: packet.id, + title: packet.title || "Untitled split release packet", + dataset: packet.dataset || {}, + splits: packet.splits || {}, + provenance: packet.provenance || {}, + release: packet.release || {}, + policy: packet.policy || {} + }; +} + +function splitNames(packet) { + return Object.keys(packet.splits || {}).map(normalizeText); +} + +function missingRequiredSplits(packet) { + const present = new Set(splitNames(packet)); + return REQUIRED_SPLITS.filter((name) => !present.has(name)); +} + +function idsForSplit(packet, splitName, key) { + return new Set(list(packet.splits[splitName]?.[key]).map(normalizeText).filter(Boolean)); +} + +function intersect(left, right) { + return [...left].filter((item) => right.has(item)); +} + +function pairwiseOverlaps(packet, key) { + const names = splitNames(packet); + const overlaps = []; + for (let i = 0; i < names.length; i += 1) { + for (let j = i + 1; j < names.length; j += 1) { + const a = names[i]; + const b = names[j]; + const values = intersect(idsForSplit(packet, a, key), idsForSplit(packet, b, key)); + if (values.length > 0) overlaps.push({ left: a, right: b, key, values }); + } + } + return overlaps; +} + +function allSplitRecords(packet) { + return splitNames(packet).reduce((total, name) => total + Number(packet.splits[name]?.recordCount || 0), 0); +} + +function hasChecksumCoverage(packet) { + return splitNames(packet).every((name) => packet.splits[name]?.checksum && packet.splits[name]?.manifestHash); +} + +function hasLicenseAccessParity(packet) { + const licenses = new Set(splitNames(packet).map((name) => normalizeText(packet.splits[name]?.license)).filter(Boolean)); + const access = new Set(splitNames(packet).map((name) => normalizeText(packet.splits[name]?.access)).filter(Boolean)); + return licenses.size <= 1 && access.size <= 1 && licenses.size > 0 && access.size > 0; +} + +function classBalanceDrift(packet) { + const names = splitNames(packet); + const target = packet.policy.maxClassBalanceDelta ?? 0.15; + const drifts = []; + for (const name of names) { + const balance = packet.splits[name]?.classBalance || {}; + for (const [label, value] of Object.entries(balance)) { + const expected = Number(packet.dataset.expectedClassBalance?.[label]); + if (Number.isFinite(expected) && Math.abs(Number(value) - expected) > target) { + drifts.push({ split: name, label, actual: Number(value), expected, delta: Number(Math.abs(Number(value) - expected).toFixed(3)) }); + } + } + } + return drifts; +} + +function embargoViolations(packet) { + return splitNames(packet).flatMap((name) => { + const rows = list(packet.splits[name]?.embargoedRecordIds); + return rows.map((recordId) => ({ split: name, recordId })); + }); +} + +function analyzePacket(input) { + const packet = normalizePacket(input); + const findings = []; + const missing = missingRequiredSplits(packet); + const recordOverlaps = pairwiseOverlaps(packet, "recordIds"); + const groupOverlaps = pairwiseOverlaps(packet, "groupIds"); + const drifts = classBalanceDrift(packet); + const embargoed = embargoViolations(packet); + + if (missing.length > 0) { + findings.push(finding( + "REQUIRED_SPLIT_MISSING", + "critical", + "The release packet lacks one or more required train/validation/test split manifests.", + { missingSplits: missing }, + "Hold release until all required split manifests are present and checksummed." + )); + } + + if (allSplitRecords(packet) !== Number(packet.dataset.recordCount || 0)) { + findings.push(finding( + "SPLIT_RECORD_TOTAL_MISMATCH", + "major", + "The split record totals do not match the dataset record count.", + { datasetRecordCount: Number(packet.dataset.recordCount || 0), splitRecordTotal: allSplitRecords(packet) }, + "Reconcile split manifests against the registered dataset row count before release." + )); + } + + if (recordOverlaps.length > 0) { + findings.push(finding( + "TRAIN_VALIDATION_TEST_RECORD_OVERLAP", + "critical", + "One or more records appear in multiple splits.", + { overlaps: recordOverlaps }, + "Regenerate split manifests with disjoint record identifiers and new manifest hashes." + )); + } + + if (groupOverlaps.length > 0 && packet.policy.requireGroupDisjointness !== false) { + findings.push(finding( + "GROUP_LEAKAGE_ACROSS_SPLITS", + "critical", + "A participant, patient, subject, lab, or other grouping identifier appears across multiple splits.", + { overlaps: groupOverlaps }, + "Regenerate splits with group-level disjointness to prevent leakage across train/validation/test boundaries." + )); + } + + if (packet.provenance.seedLocked !== true || packet.provenance.splitScriptHashPresent !== true) { + findings.push(finding( + "SPLIT_PROVENANCE_INCOMPLETE", + "major", + "The split generation seed or script hash is missing.", + { + seedLocked: packet.provenance.seedLocked || false, + splitScriptHashPresent: packet.provenance.splitScriptHashPresent || false + }, + "Attach deterministic seed and split-script hash evidence before DOI/API release." + )); + } + + if (!hasChecksumCoverage(packet)) { + findings.push(finding( + "SPLIT_CHECKSUM_COVERAGE_MISSING", + "major", + "One or more split manifests lack checksum or manifest hash evidence.", + { splits: splitNames(packet) }, + "Add per-split content checksums and manifest hashes before release." + )); + } + + if (!hasLicenseAccessParity(packet)) { + findings.push(finding( + "SPLIT_LICENSE_ACCESS_PARITY_MISMATCH", + "major", + "Split manifests have inconsistent license or access states.", + { + licenses: splitNames(packet).map((name) => ({ split: name, license: packet.splits[name]?.license || null })), + access: splitNames(packet).map((name) => ({ split: name, access: packet.splits[name]?.access || null })) + }, + "Align license and access state across splits or document why a split must remain restricted." + )); + } + + if (drifts.length > 0) { + findings.push(finding( + "STRATIFICATION_BALANCE_DRIFT", + "major", + "Class or stratum balance differs from the dataset policy beyond the allowed threshold.", + { drifts }, + "Regenerate stratified splits or document the imbalance in the release notes." + )); + } + + if (embargoed.length > 0) { + findings.push(finding( + "EMBARGOED_RECORD_IN_PUBLIC_SPLIT", + "critical", + "Embargoed records are present in one or more release split manifests.", + { embargoedRecords: embargoed }, + "Remove embargoed records or keep the affected split private until embargo restrictions are lifted." + )); + } + + if ( + findings.length > 0 && + packet.release.publicDoiRelease === true && + packet.release.freezePublicRelease !== true + ) { + findings.push(finding( + "PUBLIC_RELEASE_NOT_FROZEN_FOR_SPLIT_RISK", + "critical", + "Public DOI/API release is enabled while split-integrity findings remain unresolved.", + { + publicDoiRelease: packet.release.publicDoiRelease, + freezePublicRelease: packet.release.freezePublicRelease || false + }, + "Freeze DOI/API/public-preview release until split integrity issues are resolved." + )); + } + + const decision = decisionFor(findings); + return { + id: packet.id, + title: packet.title, + decision, + splitNames: splitNames(packet), + findings, + recommendedActions: buildRecommendedActions(decision, findings) + }; +} + +function decisionFor(findings) { + if (findings.some((item) => item.severity === "critical")) return "HOLD_RELEASE"; + if (findings.some((item) => item.severity === "major")) return "REVIEW_RELEASE"; + return "RELEASE"; +} + +function buildRecommendedActions(decision, findings) { + if (decision === "RELEASE") { + return ["Release split manifests with normal artifact provenance."]; + } + + const actions = [ + "Attach findings to the dataset release review packet.", + "Regenerate split manifests or document curator-approved exceptions.", + "Re-run checksum, license, access, and stratification checks before DOI/API release." + ]; + + if (findings.some((item) => item.code === "PUBLIC_RELEASE_NOT_FROZEN_FOR_SPLIT_RISK")) { + actions.unshift("Freeze public DOI/API release and reproducibility badges."); + } + + return actions; +} + +function analyzeSplitReleasePackets(packets) { + const results = list(packets).map(analyzePacket); + const counts = results.reduce((acc, item) => { + acc[item.decision] = (acc[item.decision] || 0) + 1; + return acc; + }, { RELEASE: 0, REVIEW_RELEASE: 0, HOLD_RELEASE: 0 }); + + return { + generatedAt: new Date().toISOString(), + totalPackets: results.length, + counts, + results + }; +} + +module.exports = { + analyzePacket, + analyzeSplitReleasePackets, + missingRequiredSplits, + pairwiseOverlaps, + classBalanceDrift +}; diff --git a/dataset-split-release-guard/test/dataset-split-release-guard.test.js b/dataset-split-release-guard/test/dataset-split-release-guard.test.js new file mode 100644 index 00000000..61798737 --- /dev/null +++ b/dataset-split-release-guard/test/dataset-split-release-guard.test.js @@ -0,0 +1,56 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { analyzePacket, analyzeSplitReleasePackets, missingRequiredSplits, pairwiseOverlaps, classBalanceDrift } = require("../src"); +const samples = require("../data/sample_split_release_packets.json"); + +test("detects missing required split manifests", () => { + const missing = missingRequiredSplits(samples.find((item) => item.id === "split-hold-004")); + assert.deepEqual(missing, ["test"]); +}); + +test("detects record and group overlap across splits", () => { + const packet = samples.find((item) => item.id === "split-hold-002"); + assert.equal(pairwiseOverlaps(packet, "recordIds").length, 1); + assert.equal(pairwiseOverlaps(packet, "groupIds").length, 1); +}); + +test("detects stratification balance drift", () => { + const drift = classBalanceDrift(samples.find((item) => item.id === "split-review-003")); + assert.ok(drift.length >= 2); +}); + +test("releases a clean split package", () => { + const result = analyzePacket(samples.find((item) => item.id === "split-release-001")); + assert.equal(result.decision, "RELEASE"); + assert.equal(result.findings.length, 0); +}); + +test("holds split packages with leakage and embargoed rows", () => { + const result = analyzePacket(samples.find((item) => item.id === "split-hold-002")); + assert.equal(result.decision, "HOLD_RELEASE"); + assert.ok(result.findings.some((item) => item.code === "TRAIN_VALIDATION_TEST_RECORD_OVERLAP")); + assert.ok(result.findings.some((item) => item.code === "GROUP_LEAKAGE_ACROSS_SPLITS")); + assert.ok(result.findings.some((item) => item.code === "EMBARGOED_RECORD_IN_PUBLIC_SPLIT")); + assert.ok(result.findings.some((item) => item.code === "PUBLIC_RELEASE_NOT_FROZEN_FOR_SPLIT_RISK")); +}); + +test("routes provenance and balance issues for review", () => { + const result = analyzePacket(samples.find((item) => item.id === "split-review-003")); + assert.equal(result.decision, "REVIEW_RELEASE"); + assert.ok(result.findings.some((item) => item.code === "SPLIT_PROVENANCE_INCOMPLETE")); + assert.ok(result.findings.some((item) => item.code === "STRATIFICATION_BALANCE_DRIFT")); +}); + +test("holds missing test split before public release", () => { + const result = analyzePacket(samples.find((item) => item.id === "split-hold-004")); + assert.equal(result.decision, "HOLD_RELEASE"); + assert.ok(result.findings.some((item) => item.code === "REQUIRED_SPLIT_MISSING")); +}); + +test("aggregates split release decisions", () => { + const report = analyzeSplitReleasePackets(samples); + assert.equal(report.totalPackets, 4); + assert.deepEqual(report.counts, { RELEASE: 1, REVIEW_RELEASE: 1, HOLD_RELEASE: 2 }); +});