From 1176ad7b7e4343940c76db537c1530b226ff67cd Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:05:50 -0400 Subject: [PATCH] Add sample custody quality graph guard --- sample-custody-quality-graph-guard/.gitignore | 1 + sample-custody-quality-graph-guard/README.md | 40 ++ sample-custody-quality-graph-guard/demo.js | 42 ++ sample-custody-quality-graph-guard/index.js | 434 ++++++++++++++++++ .../make-demo-video.js | 127 +++++ .../package.json | 21 + .../reports/clean-audit.json | 30 ++ .../reports/demo.mp4 | Bin 0 -> 16644 bytes .../reports/manifest.json | 26 ++ .../reports/risky-audit.json | 231 ++++++++++ .../reports/risky-review.md | 39 ++ .../reports/summary.svg | 13 + .../sample-data.js | 108 +++++ sample-custody-quality-graph-guard/test.js | 47 ++ 14 files changed, 1159 insertions(+) create mode 100644 sample-custody-quality-graph-guard/.gitignore create mode 100644 sample-custody-quality-graph-guard/README.md create mode 100644 sample-custody-quality-graph-guard/demo.js create mode 100644 sample-custody-quality-graph-guard/index.js create mode 100644 sample-custody-quality-graph-guard/make-demo-video.js create mode 100644 sample-custody-quality-graph-guard/package.json create mode 100644 sample-custody-quality-graph-guard/reports/clean-audit.json create mode 100644 sample-custody-quality-graph-guard/reports/demo.mp4 create mode 100644 sample-custody-quality-graph-guard/reports/manifest.json create mode 100644 sample-custody-quality-graph-guard/reports/risky-audit.json create mode 100644 sample-custody-quality-graph-guard/reports/risky-review.md create mode 100644 sample-custody-quality-graph-guard/reports/summary.svg create mode 100644 sample-custody-quality-graph-guard/sample-data.js create mode 100644 sample-custody-quality-graph-guard/test.js 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 0000000000000000000000000000000000000000..ddef49c676cec6868384eed0fcfb56b9daf93739 GIT binary patch literal 16644 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 literal 0 HcmV?d00001 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.");