diff --git a/sample-custody-quality-graph-guard/.gitignore b/sample-custody-quality-graph-guard/.gitignore new file mode 100644 index 00000000..2bf074d6 --- /dev/null +++ b/sample-custody-quality-graph-guard/.gitignore @@ -0,0 +1 @@ +reports/frames/ diff --git a/sample-custody-quality-graph-guard/README.md b/sample-custody-quality-graph-guard/README.md new file mode 100644 index 00000000..e84de0f1 --- /dev/null +++ b/sample-custody-quality-graph-guard/README.md @@ -0,0 +1,40 @@ +# Sample Custody Quality Graph Guard + +Self-contained reviewer artifact for SCIBASE issue #17, focused on sample custody and cold-chain quality before sample nodes are exposed in scientific knowledge graph entity pages or recommendations. + +This slice is intentionally narrow. It does not duplicate broad graph extraction/navigation, geospatial sample provenance, organism/strain boundaries, biological accession crosswalks, measurement harmonization, software dependency provenance, evidence freshness, temporal validity, image metadata provenance, funding provenance, or clinical-trial registry work. + +## What It Checks + +- Collection provenance has timestamps, site ids, and protocol ids. +- Preservation protocol and preservation timing are present and inside policy windows. +- Chain-of-custody handoffs are complete, signed, and contiguous. +- Cold-chain temperature logs exist and have no policy-breaking excursions. +- Freeze-thaw cycles and integrity scores meet quality thresholds. +- Required sample-to-protocol, sample-to-dataset, and sample-to-assay graph edges are present. +- Unsafe sample graph edges are held with deterministic remediation actions. + +## Local Verification + +```sh +npm run check +npm test +npm run demo +npm run verify-video +``` + +`npm run demo` writes reviewer artifacts to `reports/`: + +- `clean-audit.json` +- `risky-audit.json` +- `risky-review.md` +- `summary.svg` +- `manifest.json` +- `demo.mp4` + +## Requirement Mapping + +- Scientific knowledge graph integration: evaluates sample nodes before entity pages and recommendations publish. +- Provenance quality: links custody, preservation, temperature, and assay-quality evidence to graph edge publication. +- Recommendation safety: holds degraded or uncertain sample edges before they can influence discovery paths. +- Reviewer demonstration: synthetic clean/risky packets produce JSON, Markdown, SVG, and MP4 artifacts without credentials or external services. diff --git a/sample-custody-quality-graph-guard/demo.js b/sample-custody-quality-graph-guard/demo.js new file mode 100644 index 00000000..b790d7f3 --- /dev/null +++ b/sample-custody-quality-graph-guard/demo.js @@ -0,0 +1,42 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { + evaluateSampleCustodyGraph, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const clean = evaluateSampleCustodyGraph(cleanPacket, { now: "2026-06-01T10:45:00.000Z" }); +const risky = evaluateSampleCustodyGraph(riskyPacket, { now: "2026-06-01T10:45:00.000Z" }); +const manifest = { + module: "sample-custody-quality-graph-guard", + issue: 17, + generatedAt: "2026-06-01T10:45:00.000Z", + scenarios: [ + { name: "clean", status: clean.status, fingerprint: clean.fingerprint, findings: clean.findings.length }, + { name: "risky", status: risky.status, fingerprint: risky.fingerprint, findings: risky.findings.length } + ], + artifacts: [ + "reports/clean-audit.json", + "reports/risky-audit.json", + "reports/risky-review.md", + "reports/summary.svg", + "reports/demo.mp4" + ] +}; + +fs.writeFileSync(path.join(reportsDir, "clean-audit.json"), `${JSON.stringify(clean, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-audit.json"), `${JSON.stringify(risky, null, 2)}\n`); +fs.writeFileSync(path.join(reportsDir, "risky-review.md"), renderMarkdownReport(risky, riskyPacket)); +fs.writeFileSync(path.join(reportsDir, "summary.svg"), renderSvgSummary(risky)); +fs.writeFileSync(path.join(reportsDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + +console.log(`Clean status: ${clean.status} (${clean.fingerprint})`); +console.log(`Risky status: ${risky.status} (${risky.fingerprint})`); +console.log(`Wrote reviewer artifacts to ${reportsDir}`); diff --git a/sample-custody-quality-graph-guard/index.js b/sample-custody-quality-graph-guard/index.js new file mode 100644 index 00000000..37faf621 --- /dev/null +++ b/sample-custody-quality-graph-guard/index.js @@ -0,0 +1,434 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const SEVERITY_ORDER = ["critical", "high", "warning", "info"]; + +function evaluateSampleCustodyGraph(packet, options = {}) { + if (!isPlainObject(packet)) { + throw new TypeError("evaluateSampleCustodyGraph expects a packet object"); + } + + const now = options.now ?? new Date().toISOString(); + const graph = isPlainObject(packet.graph) ? packet.graph : {}; + const policy = isPlainObject(packet.policy) ? packet.policy : {}; + const samples = asArray(packet.samples); + const findings = []; + + if (!graph.id || !graph.releaseTarget) { + findings.push( + finding( + "PACKET_SCHEMA_MISSING_GRAPH", + "high", + "Sample custody packet is missing graph id or release target.", + "Entity-page and recommendation decisions need a stable graph release context.", + "graph", + "Attach graph release metadata before custody validation.", + "graph curator" + ) + ); + } + + if (samples.length === 0) { + findings.push( + finding( + "PACKET_SCHEMA_MISSING_SAMPLES", + "high", + "Sample custody packet has no samples to inspect.", + "The guard cannot prove quality of sample graph edges without sample nodes.", + "samples", + "Attach sample nodes and custody evidence.", + "graph curator" + ) + ); + } + + samples.forEach((sample, index) => inspectSample(sample, index, policy, findings)); + + const sortedFindings = sortFindings(findings); + const status = determineStatus(sortedFindings); + const summary = summarize(status, sortedFindings, samples.length); + const safeEdges = samples + .filter((sample) => !sortedFindings.some((item) => item.sampleId === sample.id && ["critical", "high"].includes(item.severity))) + .map((sample) => ({ + sampleId: sample.id, + publishableEdges: sample.graphEdges ?? [], + qualityTier: "verified-custody" + })); + const heldEdges = samples + .filter((sample) => sortedFindings.some((item) => item.sampleId === sample.id && ["critical", "high"].includes(item.severity))) + .map((sample) => ({ + sampleId: sample.id, + heldEdges: sample.graphEdges ?? [], + reasonCodes: sortedFindings.filter((item) => item.sampleId === sample.id).map((item) => item.code) + })); + const remediationActions = sortedFindings.map((item) => ({ + code: item.code, + sampleId: item.sampleId ?? null, + owner: item.owner, + action: item.remediation + })); + const fingerprint = crypto + .createHash("sha256") + .update( + JSON.stringify({ + graph, + policy, + samples: samples.map((sample) => ({ + id: sample.id, + collection: sample.collection, + preservation: sample.preservation, + custodyEvents: sample.custodyEvents, + quality: sample.quality, + graphEdges: sample.graphEdges + })), + codes: sortedFindings.map((item) => item.code) + }) + ) + .digest("hex") + .slice(0, 16); + + return { + generatedAt: now, + status, + summary, + findingCounts: countBySeverity(sortedFindings), + findings: sortedFindings, + safeEdges, + heldEdges, + remediationActions, + fingerprint + }; +} + +function renderMarkdownReport(result, packet) { + const lines = [ + "# Sample Custody Quality Graph Guard", + "", + `Graph: ${packet.graph?.id ?? "unknown"}`, + `Status: ${result.status}`, + `Fingerprint: ${result.fingerprint}`, + "", + "## Summary", + "", + result.summary, + "", + "## Held Edges", + "" + ]; + + if (result.heldEdges.length === 0) { + lines.push("- No graph edges were held."); + } else { + result.heldEdges.forEach((item) => { + lines.push(`- ${item.sampleId}: ${item.reasonCodes.join(", ")}`); + }); + } + + lines.push("", "## Findings", ""); + if (result.findings.length === 0) { + lines.push("- No custody or cold-chain blockers found."); + } else { + result.findings.forEach((item) => { + lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`); + lines.push(` - Remediation: ${item.remediation}`); + }); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(result) { + const counts = result.findingCounts; + const critical = counts.critical ?? 0; + const high = counts.high ?? 0; + const warning = counts.warning ?? 0; + const ready = result.status === "READY"; + const statusColor = ready ? "#16794c" : result.status === "REVISE" ? "#a15c00" : "#a11b32"; + const holdWidth = Math.min(320, (critical + high) * 60); + const warningWidth = Math.min(220, warning * 55); + const readyWidth = ready ? 280 : Math.max(75, 280 - holdWidth); + + return [ + ``, + ``, + ``, + `Sample custody graph guard`, + `Status ${escapeXml(result.status)} - fingerprint ${escapeXml(result.fingerprint)}`, + ``, + ``, + ``, + `EDGES`, + `Held sample edges: ${result.heldEdges.length}`, + `Critical/high blockers: ${critical + high}`, + `Safe sample edges: ${result.safeEdges.length}`, + `` + ].join("\n"); +} + +function inspectSample(sample, index, policy, findings) { + const sampleId = sample.id ?? `sample-${index}`; + const path = `samples[${index}]`; + const custodyEvents = asArray(sample.custodyEvents).slice().sort((left, right) => timeValue(left.at) - timeValue(right.at)); + const preservation = isPlainObject(sample.preservation) ? sample.preservation : {}; + const collection = isPlainObject(sample.collection) ? sample.collection : {}; + const quality = isPlainObject(sample.quality) ? sample.quality : {}; + + if (!sample.id) { + findings.push( + finding( + "SAMPLE_MISSING_ID", + "high", + `Sample at index ${index} has no stable id.`, + "Knowledge graph sample edges need stable sample identifiers.", + `${path}.id`, + "Assign a stable sample id before graph publication.", + "data curator", + sampleId + ) + ); + } + + if (!collection.collectedAt || !collection.siteId || !collection.protocolId) { + findings.push( + finding( + "COLLECTION_PROVENANCE_INCOMPLETE", + "high", + `${sampleId} is missing collection time, site id, or protocol id.`, + "Custody quality cannot be evaluated without collection provenance.", + `${path}.collection`, + "Attach collection timestamp, site id, and protocol evidence.", + "field lead", + sampleId + ) + ); + } + + if (!preservation.protocolId || !preservation.preservedAt) { + findings.push( + finding( + "PRESERVATION_PROTOCOL_MISSING", + "high", + `${sampleId} is missing preservation protocol evidence.`, + "Samples should not create trusted graph edges without preservation records.", + `${path}.preservation`, + "Attach preservation protocol id and timestamp.", + "biobank curator", + sampleId + ) + ); + } else if (collection.collectedAt) { + const delayHours = (timeValue(preservation.preservedAt) - timeValue(collection.collectedAt)) / 3600000; + if (delayHours > Number(policy.maxCollectionToPreservationHours ?? 6)) { + findings.push( + finding( + "COLLECTION_TO_PRESERVATION_DELAY", + "high", + `${sampleId} waited ${delayHours.toFixed(1)} hours before preservation.`, + "Long delays can degrade molecular measurements and mislead graph recommendations.", + `${path}.preservation.preservedAt`, + "Hold sample edges until degradation impact is reviewed or replacement evidence is supplied.", + "biobank curator", + sampleId + ) + ); + } + } + + if (custodyEvents.length === 0) { + findings.push( + finding( + "CUSTODY_CHAIN_MISSING", + "critical", + `${sampleId} has no chain-of-custody events.`, + "Graph edges should not trust sample identity without custody evidence.", + `${path}.custodyEvents`, + "Attach signed handoff events from collection through analysis.", + "custody officer", + sampleId + ) + ); + } else { + inspectCustodyEvents(custodyEvents, path, sampleId, findings); + } + + const tempEvents = asArray(sample.temperatureLog); + const minTemp = Number(policy.temperatureRangeC?.min ?? -90); + const maxTemp = Number(policy.temperatureRangeC?.max ?? -60); + const excursions = tempEvents.filter((event) => Number(event.celsius) < minTemp || Number(event.celsius) > maxTemp); + if (tempEvents.length === 0) { + findings.push( + finding( + "TEMPERATURE_LOG_MISSING", + "high", + `${sampleId} has no cold-chain temperature log.`, + "Cold-chain evidence protects downstream graph conclusions from degraded samples.", + `${path}.temperatureLog`, + "Attach temperature logger evidence before publishing sample edges.", + "biobank curator", + sampleId + ) + ); + } else if (excursions.length > Number(policy.maxTemperatureExcursions ?? 0)) { + findings.push( + finding( + "COLD_CHAIN_TEMPERATURE_EXCURSION", + "critical", + `${sampleId} has ${excursions.length} temperature excursion(s) outside policy.`, + "Temperature excursions can invalidate assay/sample relationships.", + `${path}.temperatureLog`, + "Hold graph edges until excursion review and assay impact notes are attached.", + "quality reviewer", + sampleId + ) + ); + } + + if (Number(quality.freezeThawCycles ?? 0) > Number(policy.maxFreezeThawCycles ?? 2)) { + findings.push( + finding( + "FREEZE_THAW_LIMIT_EXCEEDED", + "high", + `${sampleId} exceeds the allowed freeze-thaw cycle limit.`, + "Repeated thawing can degrade sample analytes and invalidate reuse recommendations.", + `${path}.quality.freezeThawCycles`, + "Suppress reuse/recommendation edges or attach validated degradation analysis.", + "quality reviewer", + sampleId + ) + ); + } + + if (quality.integrityScore != null && Number(quality.integrityScore) < Number(policy.minIntegrityScore ?? 0.82)) { + findings.push( + finding( + "SAMPLE_INTEGRITY_BELOW_THRESHOLD", + "high", + `${sampleId} has integrity score ${quality.integrityScore}, below policy threshold.`, + "Low-integrity samples should not be recommended as high-confidence evidence.", + `${path}.quality.integrityScore`, + "Downgrade or hold sample graph edges until a curator reviews quality evidence.", + "quality reviewer", + sampleId + ) + ); + } + + const missingRequiredEdges = asArray(policy.requiredGraphEdges).filter((edgeType) => !asArray(sample.graphEdges).some((edge) => edge.type === edgeType)); + if (missingRequiredEdges.length > 0) { + findings.push( + finding( + "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING", + "warning", + `${sampleId} is missing required graph edge type(s): ${missingRequiredEdges.join(", ")}.`, + "Entity pages need complete sample-to-protocol/dataset/evidence linkage.", + `${path}.graphEdges`, + "Attach the missing edge evidence or mark the sample as partial provenance.", + "graph curator", + sampleId + ) + ); + } +} + +function inspectCustodyEvents(custodyEvents, path, sampleId, findings) { + custodyEvents.forEach((event, eventIndex) => { + if (!event.at || !event.from || !event.to || !event.signature) { + findings.push( + finding( + "CUSTODY_HANDOFF_INCOMPLETE", + "high", + `${sampleId} has an incomplete custody handoff at event ${eventIndex}.`, + "Each transfer should identify source, recipient, timestamp, and signature.", + `${path}.custodyEvents[${eventIndex}]`, + "Complete the signed custody handoff record.", + "custody officer", + sampleId + ) + ); + } + }); + + for (let index = 1; index < custodyEvents.length; index += 1) { + if (custodyEvents[index - 1].to !== custodyEvents[index].from) { + findings.push( + finding( + "CUSTODY_CHAIN_BREAK", + "critical", + `${sampleId} custody chain breaks between events ${index - 1} and ${index}.`, + "Unexplained custody gaps undermine sample identity and provenance.", + `${path}.custodyEvents[${index}]`, + "Insert the missing transfer or hold graph publication for the sample.", + "custody officer", + sampleId + ) + ); + } + } +} + +function determineStatus(findings) { + if (findings.some((item) => item.severity === "critical" || item.severity === "high")) { + return "HOLD"; + } + if (findings.some((item) => item.severity === "warning")) { + return "REVISE"; + } + return "READY"; +} + +function summarize(status, findings, sampleCount) { + if (status === "READY") { + return `All ${sampleCount} sample node(s) have custody, preservation, cold-chain, and quality evidence for graph publication.`; + } + const counts = countBySeverity(findings); + return `Sample graph publication is ${status.toLowerCase()} with ${counts.critical ?? 0} critical, ${counts.high ?? 0} high, and ${counts.warning ?? 0} warning finding(s).`; +} + +function finding(code, severity, message, impact, path, remediation, owner = "graph curator", sampleId = null) { + return { code, severity, message, impact, path, remediation, owner, sampleId }; +} + +function sortFindings(findings) { + return findings.sort((left, right) => { + const severityDelta = SEVERITY_ORDER.indexOf(left.severity) - SEVERITY_ORDER.indexOf(right.severity); + if (severityDelta !== 0) { + return severityDelta; + } + return left.code.localeCompare(right.code); + }); +} + +function countBySeverity(findings) { + return findings.reduce((counts, item) => { + counts[item.severity] = (counts[item.severity] ?? 0) + 1; + return counts; + }, {}); +} + +function timeValue(value) { + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +module.exports = { + evaluateSampleCustodyGraph, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/sample-custody-quality-graph-guard/make-demo-video.js b/sample-custody-quality-graph-guard/make-demo-video.js new file mode 100644 index 00000000..ca93e374 --- /dev/null +++ b/sample-custody-quality-graph-guard/make-demo-video.js @@ -0,0 +1,127 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const WIDTH = 960; +const HEIGHT = 540; +const FONT = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], + V: ["10001", "10001", "10001", "10001", "01010", "01010", "00100"], + X: ["10001", "01010", "00100", "00100", "00100", "01010", "10001"], + Y: ["10001", "01010", "00100", "00100", "00100", "00100", "00100"] +}; + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +for (const file of fs.readdirSync(framesDir)) { + fs.unlinkSync(path.join(framesDir, file)); +} + +const slides = [ + { label: "CUSTODY", color: [22, 121, 76], fill: 0.88 }, + { label: "COLD CHAIN", color: [161, 27, 50], fill: 0.38 }, + { label: "GRAPH EDGES", color: [22, 121, 76], fill: 0.82 } +]; + +let frameIndex = 0; +for (const slide of slides) { + for (let i = 0; i < 8; i += 1) { + const progress = (i + 1) / 8; + const buffer = createFrame(slide, progress); + fs.writeFileSync(path.join(framesDir, `frame-${String(frameIndex).padStart(3, "0")}.ppm`), buffer); + frameIndex += 1; + } +} + +const output = path.join(reportsDir, "demo.mp4"); +execFileSync( + "ffmpeg", + [ + "-y", + "-framerate", + "8", + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output + ], + { stdio: "ignore" } +); + +const stats = fs.statSync(output); +console.log(`Wrote ${output} (${stats.size} bytes)`); + +function createFrame(slide, progress) { + const pixels = Buffer.alloc(WIDTH * HEIGHT * 3); + fillRect(pixels, 0, 0, WIDTH, HEIGHT, [17, 24, 39]); + fillRect(pixels, 48, 48, 864, 444, [248, 250, 252]); + fillRect(pixels, 80, 190, 800, 88, [226, 232, 240]); + fillRect(pixels, 80, 190, Math.round(800 * slide.fill * progress), 88, slide.color); + fillRect(pixels, 80, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 344, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 608, 322, 240, 42, [226, 232, 240]); + fillRect(pixels, 80, 322, 70, 42, [161, 27, 50]); + fillRect(pixels, 344, 322, 140, 42, [161, 92, 0]); + fillRect(pixels, 608, 322, 210, 42, [22, 121, 76]); + drawText(pixels, "SAMPLE GUARD", 82, 104, 5, [17, 24, 39]); + drawText(pixels, slide.label, 108, 214, 7, [255, 255, 255]); + drawText(pixels, "QUALITY GRAPH", 82, 414, 4, [51, 65, 85]); + return Buffer.concat([Buffer.from(`P6\n${WIDTH} ${HEIGHT}\n255\n`, "ascii"), pixels]); +} + +function fillRect(pixels, x, y, width, height, color) { + const x2 = Math.min(WIDTH, x + width); + const y2 = Math.min(HEIGHT, y + height); + for (let row = Math.max(0, y); row < y2; row += 1) { + for (let col = Math.max(0, x); col < x2; col += 1) { + const offset = (row * WIDTH + col) * 3; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + } + } +} + +function drawText(pixels, text, x, y, scale, color) { + let cursor = x; + for (const rawChar of text) { + const char = rawChar.toUpperCase(); + if (char === " ") { + cursor += 4 * scale; + continue; + } + const glyph = FONT[char]; + if (!glyph) { + cursor += 6 * scale; + continue; + } + glyph.forEach((row, rowIndex) => { + for (let colIndex = 0; colIndex < row.length; colIndex += 1) { + if (row[colIndex] === "1") { + fillRect(pixels, cursor + colIndex * scale, y + rowIndex * scale, scale, scale, color); + } + } + }); + cursor += 6 * scale; + } +} diff --git a/sample-custody-quality-graph-guard/package.json b/sample-custody-quality-graph-guard/package.json new file mode 100644 index 00000000..65c0c60b --- /dev/null +++ b/sample-custody-quality-graph-guard/package.json @@ -0,0 +1,21 @@ +{ + "name": "sample-custody-quality-graph-guard", + "version": "1.0.0", + "private": true, + "description": "Synthetic sample custody and cold-chain quality guard for scientific knowledge graph edges.", + "main": "index.js", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check test.js && node --check demo.js && node --check make-demo-video.js", + "test": "node test.js", + "demo": "node demo.js && node make-demo-video.js", + "verify-video": "ffprobe -v error -show_entries stream=codec_name,width,height,duration -of default=nokey=1:noprint_wrappers=1 reports/demo.mp4" + }, + "keywords": [ + "scientific-knowledge-graph", + "sample-custody", + "cold-chain", + "provenance", + "synthetic" + ], + "license": "MIT" +} diff --git a/sample-custody-quality-graph-guard/reports/clean-audit.json b/sample-custody-quality-graph-guard/reports/clean-audit.json new file mode 100644 index 00000000..9d9c11dd --- /dev/null +++ b/sample-custody-quality-graph-guard/reports/clean-audit.json @@ -0,0 +1,30 @@ +{ + "generatedAt": "2026-06-01T10:45:00.000Z", + "status": "READY", + "summary": "All 1 sample node(s) have custody, preservation, cold-chain, and quality evidence for graph publication.", + "findingCounts": {}, + "findings": [], + "safeEdges": [ + { + "sampleId": "SAMPLE-ALPHA", + "publishableEdges": [ + { + "type": "sample-to-protocol", + "target": "PROTO-CRYO-1" + }, + { + "type": "sample-to-dataset", + "target": "DATASET-77" + }, + { + "type": "sample-to-assay", + "target": "ASSAY-RNA-2" + } + ], + "qualityTier": "verified-custody" + } + ], + "heldEdges": [], + "remediationActions": [], + "fingerprint": "4fc0bc78f8608fad" +} diff --git a/sample-custody-quality-graph-guard/reports/demo.mp4 b/sample-custody-quality-graph-guard/reports/demo.mp4 new file mode 100644 index 00000000..ddef49c6 Binary files /dev/null and b/sample-custody-quality-graph-guard/reports/demo.mp4 differ diff --git a/sample-custody-quality-graph-guard/reports/manifest.json b/sample-custody-quality-graph-guard/reports/manifest.json new file mode 100644 index 00000000..48fda537 --- /dev/null +++ b/sample-custody-quality-graph-guard/reports/manifest.json @@ -0,0 +1,26 @@ +{ + "module": "sample-custody-quality-graph-guard", + "issue": 17, + "generatedAt": "2026-06-01T10:45:00.000Z", + "scenarios": [ + { + "name": "clean", + "status": "READY", + "fingerprint": "4fc0bc78f8608fad", + "findings": 0 + }, + { + "name": "risky", + "status": "HOLD", + "fingerprint": "92285cf83c973c8e", + "findings": 11 + } + ], + "artifacts": [ + "reports/clean-audit.json", + "reports/risky-audit.json", + "reports/risky-review.md", + "reports/summary.svg", + "reports/demo.mp4" + ] +} diff --git a/sample-custody-quality-graph-guard/reports/risky-audit.json b/sample-custody-quality-graph-guard/reports/risky-audit.json new file mode 100644 index 00000000..5eac61a5 --- /dev/null +++ b/sample-custody-quality-graph-guard/reports/risky-audit.json @@ -0,0 +1,231 @@ +{ + "generatedAt": "2026-06-01T10:45:00.000Z", + "status": "HOLD", + "summary": "Sample graph publication is hold with 3 critical, 6 high, and 2 warning finding(s).", + "findingCounts": { + "critical": 3, + "high": 6, + "warning": 2 + }, + "findings": [ + { + "code": "COLD_CHAIN_TEMPERATURE_EXCURSION", + "severity": "critical", + "message": "SAMPLE-BETA has 1 temperature excursion(s) outside policy.", + "impact": "Temperature excursions can invalidate assay/sample relationships.", + "path": "samples[0].temperatureLog", + "remediation": "Hold graph edges until excursion review and assay impact notes are attached.", + "owner": "quality reviewer", + "sampleId": "SAMPLE-BETA" + }, + { + "code": "CUSTODY_CHAIN_BREAK", + "severity": "critical", + "message": "SAMPLE-BETA custody chain breaks between events 0 and 1.", + "impact": "Unexplained custody gaps undermine sample identity and provenance.", + "path": "samples[0].custodyEvents[1]", + "remediation": "Insert the missing transfer or hold graph publication for the sample.", + "owner": "custody officer", + "sampleId": "SAMPLE-BETA" + }, + { + "code": "CUSTODY_CHAIN_MISSING", + "severity": "critical", + "message": "SAMPLE-GAMMA has no chain-of-custody events.", + "impact": "Graph edges should not trust sample identity without custody evidence.", + "path": "samples[1].custodyEvents", + "remediation": "Attach signed handoff events from collection through analysis.", + "owner": "custody officer", + "sampleId": "SAMPLE-GAMMA" + }, + { + "code": "COLLECTION_TO_PRESERVATION_DELAY", + "severity": "high", + "message": "SAMPLE-BETA waited 9.5 hours before preservation.", + "impact": "Long delays can degrade molecular measurements and mislead graph recommendations.", + "path": "samples[0].preservation.preservedAt", + "remediation": "Hold sample edges until degradation impact is reviewed or replacement evidence is supplied.", + "owner": "biobank curator", + "sampleId": "SAMPLE-BETA" + }, + { + "code": "CUSTODY_HANDOFF_INCOMPLETE", + "severity": "high", + "message": "SAMPLE-BETA has an incomplete custody handoff at event 1.", + "impact": "Each transfer should identify source, recipient, timestamp, and signature.", + "path": "samples[0].custodyEvents[1]", + "remediation": "Complete the signed custody handoff record.", + "owner": "custody officer", + "sampleId": "SAMPLE-BETA" + }, + { + "code": "FREEZE_THAW_LIMIT_EXCEEDED", + "severity": "high", + "message": "SAMPLE-BETA exceeds the allowed freeze-thaw cycle limit.", + "impact": "Repeated thawing can degrade sample analytes and invalidate reuse recommendations.", + "path": "samples[0].quality.freezeThawCycles", + "remediation": "Suppress reuse/recommendation edges or attach validated degradation analysis.", + "owner": "quality reviewer", + "sampleId": "SAMPLE-BETA" + }, + { + "code": "PRESERVATION_PROTOCOL_MISSING", + "severity": "high", + "message": "SAMPLE-GAMMA is missing preservation protocol evidence.", + "impact": "Samples should not create trusted graph edges without preservation records.", + "path": "samples[1].preservation", + "remediation": "Attach preservation protocol id and timestamp.", + "owner": "biobank curator", + "sampleId": "SAMPLE-GAMMA" + }, + { + "code": "SAMPLE_INTEGRITY_BELOW_THRESHOLD", + "severity": "high", + "message": "SAMPLE-BETA has integrity score 0.69, below policy threshold.", + "impact": "Low-integrity samples should not be recommended as high-confidence evidence.", + "path": "samples[0].quality.integrityScore", + "remediation": "Downgrade or hold sample graph edges until a curator reviews quality evidence.", + "owner": "quality reviewer", + "sampleId": "SAMPLE-BETA" + }, + { + "code": "TEMPERATURE_LOG_MISSING", + "severity": "high", + "message": "SAMPLE-GAMMA has no cold-chain temperature log.", + "impact": "Cold-chain evidence protects downstream graph conclusions from degraded samples.", + "path": "samples[1].temperatureLog", + "remediation": "Attach temperature logger evidence before publishing sample edges.", + "owner": "biobank curator", + "sampleId": "SAMPLE-GAMMA" + }, + { + "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING", + "severity": "warning", + "message": "SAMPLE-BETA is missing required graph edge type(s): sample-to-assay.", + "impact": "Entity pages need complete sample-to-protocol/dataset/evidence linkage.", + "path": "samples[0].graphEdges", + "remediation": "Attach the missing edge evidence or mark the sample as partial provenance.", + "owner": "graph curator", + "sampleId": "SAMPLE-BETA" + }, + { + "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING", + "severity": "warning", + "message": "SAMPLE-GAMMA is missing required graph edge type(s): sample-to-dataset, sample-to-assay.", + "impact": "Entity pages need complete sample-to-protocol/dataset/evidence linkage.", + "path": "samples[1].graphEdges", + "remediation": "Attach the missing edge evidence or mark the sample as partial provenance.", + "owner": "graph curator", + "sampleId": "SAMPLE-GAMMA" + } + ], + "safeEdges": [], + "heldEdges": [ + { + "sampleId": "SAMPLE-BETA", + "heldEdges": [ + { + "type": "sample-to-protocol", + "target": "PROTO-CRYO-1" + }, + { + "type": "sample-to-dataset", + "target": "DATASET-88" + } + ], + "reasonCodes": [ + "COLD_CHAIN_TEMPERATURE_EXCURSION", + "CUSTODY_CHAIN_BREAK", + "COLLECTION_TO_PRESERVATION_DELAY", + "CUSTODY_HANDOFF_INCOMPLETE", + "FREEZE_THAW_LIMIT_EXCEEDED", + "SAMPLE_INTEGRITY_BELOW_THRESHOLD", + "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING" + ] + }, + { + "sampleId": "SAMPLE-GAMMA", + "heldEdges": [ + { + "type": "sample-to-protocol", + "target": "PROTO-CRYO-1" + } + ], + "reasonCodes": [ + "CUSTODY_CHAIN_MISSING", + "PRESERVATION_PROTOCOL_MISSING", + "TEMPERATURE_LOG_MISSING", + "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING" + ] + } + ], + "remediationActions": [ + { + "code": "COLD_CHAIN_TEMPERATURE_EXCURSION", + "sampleId": "SAMPLE-BETA", + "owner": "quality reviewer", + "action": "Hold graph edges until excursion review and assay impact notes are attached." + }, + { + "code": "CUSTODY_CHAIN_BREAK", + "sampleId": "SAMPLE-BETA", + "owner": "custody officer", + "action": "Insert the missing transfer or hold graph publication for the sample." + }, + { + "code": "CUSTODY_CHAIN_MISSING", + "sampleId": "SAMPLE-GAMMA", + "owner": "custody officer", + "action": "Attach signed handoff events from collection through analysis." + }, + { + "code": "COLLECTION_TO_PRESERVATION_DELAY", + "sampleId": "SAMPLE-BETA", + "owner": "biobank curator", + "action": "Hold sample edges until degradation impact is reviewed or replacement evidence is supplied." + }, + { + "code": "CUSTODY_HANDOFF_INCOMPLETE", + "sampleId": "SAMPLE-BETA", + "owner": "custody officer", + "action": "Complete the signed custody handoff record." + }, + { + "code": "FREEZE_THAW_LIMIT_EXCEEDED", + "sampleId": "SAMPLE-BETA", + "owner": "quality reviewer", + "action": "Suppress reuse/recommendation edges or attach validated degradation analysis." + }, + { + "code": "PRESERVATION_PROTOCOL_MISSING", + "sampleId": "SAMPLE-GAMMA", + "owner": "biobank curator", + "action": "Attach preservation protocol id and timestamp." + }, + { + "code": "SAMPLE_INTEGRITY_BELOW_THRESHOLD", + "sampleId": "SAMPLE-BETA", + "owner": "quality reviewer", + "action": "Downgrade or hold sample graph edges until a curator reviews quality evidence." + }, + { + "code": "TEMPERATURE_LOG_MISSING", + "sampleId": "SAMPLE-GAMMA", + "owner": "biobank curator", + "action": "Attach temperature logger evidence before publishing sample edges." + }, + { + "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING", + "sampleId": "SAMPLE-BETA", + "owner": "graph curator", + "action": "Attach the missing edge evidence or mark the sample as partial provenance." + }, + { + "code": "REQUIRED_SAMPLE_GRAPH_EDGE_MISSING", + "sampleId": "SAMPLE-GAMMA", + "owner": "graph curator", + "action": "Attach the missing edge evidence or mark the sample as partial provenance." + } + ], + "fingerprint": "92285cf83c973c8e" +} diff --git a/sample-custody-quality-graph-guard/reports/risky-review.md b/sample-custody-quality-graph-guard/reports/risky-review.md new file mode 100644 index 00000000..7e8c3b64 --- /dev/null +++ b/sample-custody-quality-graph-guard/reports/risky-review.md @@ -0,0 +1,39 @@ +# Sample Custody Quality Graph Guard + +Graph: kg-sample-custody-risky +Status: HOLD +Fingerprint: 92285cf83c973c8e + +## Summary + +Sample graph publication is hold with 3 critical, 6 high, and 2 warning finding(s). + +## Held Edges + +- SAMPLE-BETA: COLD_CHAIN_TEMPERATURE_EXCURSION, CUSTODY_CHAIN_BREAK, COLLECTION_TO_PRESERVATION_DELAY, CUSTODY_HANDOFF_INCOMPLETE, FREEZE_THAW_LIMIT_EXCEEDED, SAMPLE_INTEGRITY_BELOW_THRESHOLD, REQUIRED_SAMPLE_GRAPH_EDGE_MISSING +- SAMPLE-GAMMA: CUSTODY_CHAIN_MISSING, PRESERVATION_PROTOCOL_MISSING, TEMPERATURE_LOG_MISSING, REQUIRED_SAMPLE_GRAPH_EDGE_MISSING + +## Findings + +- CRITICAL COLD_CHAIN_TEMPERATURE_EXCURSION: SAMPLE-BETA has 1 temperature excursion(s) outside policy. + - Remediation: Hold graph edges until excursion review and assay impact notes are attached. +- CRITICAL CUSTODY_CHAIN_BREAK: SAMPLE-BETA custody chain breaks between events 0 and 1. + - Remediation: Insert the missing transfer or hold graph publication for the sample. +- CRITICAL CUSTODY_CHAIN_MISSING: SAMPLE-GAMMA has no chain-of-custody events. + - Remediation: Attach signed handoff events from collection through analysis. +- HIGH COLLECTION_TO_PRESERVATION_DELAY: SAMPLE-BETA waited 9.5 hours before preservation. + - Remediation: Hold sample edges until degradation impact is reviewed or replacement evidence is supplied. +- HIGH CUSTODY_HANDOFF_INCOMPLETE: SAMPLE-BETA has an incomplete custody handoff at event 1. + - Remediation: Complete the signed custody handoff record. +- HIGH FREEZE_THAW_LIMIT_EXCEEDED: SAMPLE-BETA exceeds the allowed freeze-thaw cycle limit. + - Remediation: Suppress reuse/recommendation edges or attach validated degradation analysis. +- HIGH PRESERVATION_PROTOCOL_MISSING: SAMPLE-GAMMA is missing preservation protocol evidence. + - Remediation: Attach preservation protocol id and timestamp. +- HIGH SAMPLE_INTEGRITY_BELOW_THRESHOLD: SAMPLE-BETA has integrity score 0.69, below policy threshold. + - Remediation: Downgrade or hold sample graph edges until a curator reviews quality evidence. +- HIGH TEMPERATURE_LOG_MISSING: SAMPLE-GAMMA has no cold-chain temperature log. + - Remediation: Attach temperature logger evidence before publishing sample edges. +- WARNING REQUIRED_SAMPLE_GRAPH_EDGE_MISSING: SAMPLE-BETA is missing required graph edge type(s): sample-to-assay. + - Remediation: Attach the missing edge evidence or mark the sample as partial provenance. +- WARNING REQUIRED_SAMPLE_GRAPH_EDGE_MISSING: SAMPLE-GAMMA is missing required graph edge type(s): sample-to-dataset, sample-to-assay. + - Remediation: Attach the missing edge evidence or mark the sample as partial provenance. diff --git a/sample-custody-quality-graph-guard/reports/summary.svg b/sample-custody-quality-graph-guard/reports/summary.svg new file mode 100644 index 00000000..41e58793 --- /dev/null +++ b/sample-custody-quality-graph-guard/reports/summary.svg @@ -0,0 +1,13 @@ + + + +Sample custody graph guard +Status HOLD - fingerprint 92285cf83c973c8e + + + +EDGES +Held sample edges: 2 +Critical/high blockers: 9 +Safe sample edges: 0 + \ No newline at end of file diff --git a/sample-custody-quality-graph-guard/sample-data.js b/sample-custody-quality-graph-guard/sample-data.js new file mode 100644 index 00000000..973ce103 --- /dev/null +++ b/sample-custody-quality-graph-guard/sample-data.js @@ -0,0 +1,108 @@ +"use strict"; + +const cleanPacket = { + graph: { + id: "kg-sample-custody-clean", + releaseTarget: "entity-pages-and-recommendations" + }, + policy: { + maxCollectionToPreservationHours: 6, + temperatureRangeC: { min: -90, max: -60 }, + maxTemperatureExcursions: 0, + maxFreezeThawCycles: 2, + minIntegrityScore: 0.82, + requiredGraphEdges: ["sample-to-protocol", "sample-to-dataset", "sample-to-assay"] + }, + samples: [ + { + id: "SAMPLE-ALPHA", + collection: { + collectedAt: "2026-05-18T10:00:00.000Z", + siteId: "FIELD-A", + protocolId: "PROTO-CRYO-1" + }, + preservation: { + protocolId: "PRESERVE-LN2", + preservedAt: "2026-05-18T12:00:00.000Z" + }, + custodyEvents: [ + { at: "2026-05-18T10:05:00.000Z", from: "collector-a", to: "field-freezer", signature: "sig-a1" }, + { at: "2026-05-18T13:00:00.000Z", from: "field-freezer", to: "biobank-1", signature: "sig-a2" }, + { at: "2026-05-19T08:00:00.000Z", from: "biobank-1", to: "assay-lab", signature: "sig-a3" } + ], + temperatureLog: [ + { at: "2026-05-18T12:00:00.000Z", celsius: -78 }, + { at: "2026-05-18T18:00:00.000Z", celsius: -75 }, + { at: "2026-05-19T06:00:00.000Z", celsius: -77 } + ], + quality: { + freezeThawCycles: 1, + integrityScore: 0.94 + }, + graphEdges: [ + { type: "sample-to-protocol", target: "PROTO-CRYO-1" }, + { type: "sample-to-dataset", target: "DATASET-77" }, + { type: "sample-to-assay", target: "ASSAY-RNA-2" } + ] + } + ] +}; + +const riskyPacket = { + graph: { + id: "kg-sample-custody-risky", + releaseTarget: "entity-pages-and-recommendations" + }, + policy: cleanPacket.policy, + samples: [ + { + id: "SAMPLE-BETA", + collection: { + collectedAt: "2026-05-18T10:00:00.000Z", + siteId: "FIELD-B", + protocolId: "PROTO-CRYO-1" + }, + preservation: { + protocolId: "PRESERVE-LN2", + preservedAt: "2026-05-18T19:30:00.000Z" + }, + custodyEvents: [ + { at: "2026-05-18T10:10:00.000Z", from: "collector-b", to: "field-freezer", signature: "sig-b1" }, + { at: "2026-05-18T16:00:00.000Z", from: "unknown-courier", to: "biobank-1", signature: "" } + ], + temperatureLog: [ + { at: "2026-05-18T12:00:00.000Z", celsius: -76 }, + { at: "2026-05-18T15:30:00.000Z", celsius: -42 } + ], + quality: { + freezeThawCycles: 4, + integrityScore: 0.69 + }, + graphEdges: [ + { type: "sample-to-protocol", target: "PROTO-CRYO-1" }, + { type: "sample-to-dataset", target: "DATASET-88" } + ] + }, + { + id: "SAMPLE-GAMMA", + collection: { + collectedAt: "2026-05-19T10:00:00.000Z", + siteId: "FIELD-C", + protocolId: "PROTO-CRYO-1" + }, + preservation: {}, + custodyEvents: [], + temperatureLog: [], + quality: { + freezeThawCycles: 0, + integrityScore: 0.91 + }, + graphEdges: [{ type: "sample-to-protocol", target: "PROTO-CRYO-1" }] + } + ] +}; + +module.exports = { + cleanPacket, + riskyPacket +}; diff --git a/sample-custody-quality-graph-guard/test.js b/sample-custody-quality-graph-guard/test.js new file mode 100644 index 00000000..28b89310 --- /dev/null +++ b/sample-custody-quality-graph-guard/test.js @@ -0,0 +1,47 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { + evaluateSampleCustodyGraph, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const clean = evaluateSampleCustodyGraph(cleanPacket, { now: "2026-06-01T10:45:00.000Z" }); +assert.equal(clean.status, "READY"); +assert.equal(clean.findings.length, 0); +assert.equal(clean.safeEdges.length, 1); +assert.equal(clean.heldEdges.length, 0); + +const risky = evaluateSampleCustodyGraph(riskyPacket, { now: "2026-06-01T10:45:00.000Z" }); +const codes = new Set(risky.findings.map((finding) => finding.code)); +assert.equal(risky.status, "HOLD"); +assert.ok(codes.has("COLLECTION_TO_PRESERVATION_DELAY")); +assert.ok(codes.has("CUSTODY_HANDOFF_INCOMPLETE")); +assert.ok(codes.has("CUSTODY_CHAIN_BREAK")); +assert.ok(codes.has("COLD_CHAIN_TEMPERATURE_EXCURSION")); +assert.ok(codes.has("FREEZE_THAW_LIMIT_EXCEEDED")); +assert.ok(codes.has("SAMPLE_INTEGRITY_BELOW_THRESHOLD")); +assert.ok(codes.has("PRESERVATION_PROTOCOL_MISSING")); +assert.ok(codes.has("CUSTODY_CHAIN_MISSING")); +assert.ok(codes.has("TEMPERATURE_LOG_MISSING")); +assert.ok(codes.has("REQUIRED_SAMPLE_GRAPH_EDGE_MISSING")); +assert.equal(risky.safeEdges.length, 0); +assert.equal(risky.heldEdges.length, 2); + +const repeatedRisky = evaluateSampleCustodyGraph(riskyPacket, { now: "2026-06-01T10:50:00.000Z" }); +assert.equal(risky.fingerprint, repeatedRisky.fingerprint); + +const markdown = renderMarkdownReport(risky, riskyPacket); +assert.match(markdown, /Sample Custody Quality Graph Guard/); +assert.match(markdown, /COLD_CHAIN_TEMPERATURE_EXCURSION/); + +const svg = renderSvgSummary(risky); +assert.match(svg, / evaluateSampleCustodyGraph(null), /expects a packet object/); + +console.log("All sample custody graph guard tests passed.");