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 [
+ ``
+ ].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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
p4}~RD##X6nA)H#9}!yIGgalAhxx?u
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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 @@
+
\ 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, /