diff --git a/protocol-deviation-graph-guard/README.md b/protocol-deviation-graph-guard/README.md
new file mode 100644
index 00000000..b90b9755
--- /dev/null
+++ b/protocol-deviation-graph-guard/README.md
@@ -0,0 +1,24 @@
+# Protocol Deviation Graph Guard
+
+Synthetic, dependency-free review module for SCIBASE issue #17, focused on experiment-to-protocol adherence before knowledge graph edges and recommendations are published.
+
+The guard audits protocol packets for:
+
+- missing required protocol steps;
+- protocol version mismatches;
+- unauthorized reagent lots;
+- expired instrument calibration;
+- parameter deviations outside declared tolerances;
+- graph edge publish/review/hold decisions.
+
+It emits JSON, Markdown, SVG, GIF, and MP4 reviewer artifacts from synthetic data only. No credentials, private studies, live graph services, payment systems, or external APIs are used.
+
+## Commands
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+Generated artifacts are written to `reports/`.
diff --git a/protocol-deviation-graph-guard/demo.js b/protocol-deviation-graph-guard/demo.js
new file mode 100644
index 00000000..a5e7caad
--- /dev/null
+++ b/protocol-deviation-graph-guard/demo.js
@@ -0,0 +1,26 @@
+const path = require("path");
+const { samplePacket } = require("./sample-data");
+const { auditProtocolDeviationGraph, writeReportArtifacts } = require("./index");
+
+const outputDir = path.join(__dirname, "reports");
+const report = auditProtocolDeviationGraph(samplePacket);
+const artifacts = writeReportArtifacts(report, outputDir);
+
+const demoScript = [
+ "Protocol Deviation Graph Guard demo",
+ "",
+ "1. Load synthetic protocol and experiment graph packets.",
+ "2. Compare observed experiment steps against required protocol steps and tolerances.",
+ "3. Check protocol version, reagent lot, and instrument calibration evidence.",
+ "4. Hold unsafe experiment-to-protocol and recommendation edges before publication.",
+ "",
+ `Decision: ${report.decision}`,
+ `Held edges: ${report.totals.holdEdges}`,
+ `Publishable edges: ${report.totals.publishEdges}`,
+ "",
+ "Synthetic data only. No private studies, credentials, live graph services, or external APIs."
+].join("\n");
+
+require("fs").writeFileSync(path.join(outputDir, "demo-script.txt"), `${demoScript}\n`);
+
+console.log(JSON.stringify({ report: artifacts, decision: report.decision, totals: report.totals }, null, 2));
diff --git a/protocol-deviation-graph-guard/demo_video.py b/protocol-deviation-graph-guard/demo_video.py
new file mode 100644
index 00000000..f046a6a5
--- /dev/null
+++ b/protocol-deviation-graph-guard/demo_video.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import imageio.v3 as iio
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parent
+REPORTS = ROOT / "reports"
+REPORTS.mkdir(exist_ok=True)
+
+
+def font(size):
+ for name in ("arial.ttf", "segoeui.ttf"):
+ try:
+ return ImageFont.truetype(name, size)
+ except OSError:
+ pass
+ return ImageFont.load_default()
+
+
+slides = [
+ ("Protocol Deviation Graph Guard", "Synthetic graph safety control for SCIBASE #17"),
+ ("Input", "protocol steps + experiment evidence + proposed graph edges"),
+ ("Findings", "missing sequencing QC + expired calibration + lot mismatch"),
+ ("Decision", "hold unsafe graph edges before recommendations publish"),
+]
+
+frames = []
+for title, subtitle in slides:
+ image = Image.new("RGB", (960, 544), "#102033")
+ draw = ImageDraw.Draw(image)
+ draw.rectangle((48, 58, 912, 486), outline="#38bdf8", width=3)
+ draw.text((82, 132), title, fill="#f8fafc", font=font(40))
+ draw.text((82, 214), subtitle, fill="#dbeafe", font=font(24))
+ draw.rectangle((82, 342, 742, 392), fill="#dc2626")
+ draw.text((102, 354), "recommendation edges held", fill="#fee2e2", font=font(24))
+ draw.text((82, 430), "Synthetic data only. No private studies or live graph APIs.", fill="#cbd5e1", font=font(20))
+ frames.extend([image] * 14)
+
+gif_path = REPORTS / "demo.gif"
+mp4_path = REPORTS / "demo.mp4"
+frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
+iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
+print(f"wrote {gif_path}")
+print(f"wrote {mp4_path}")
diff --git a/protocol-deviation-graph-guard/index.js b/protocol-deviation-graph-guard/index.js
new file mode 100644
index 00000000..5b1072c1
--- /dev/null
+++ b/protocol-deviation-graph-guard/index.js
@@ -0,0 +1,298 @@
+const fs = require("fs");
+const path = require("path");
+
+const SEVERITY_POINTS = {
+ info: 5,
+ medium: 20,
+ high: 35,
+ critical: 55
+};
+
+function asArray(value, field) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(`${field} must be an array`);
+ }
+ return value;
+}
+
+function asNonEmptyString(value, field) {
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new TypeError(`${field} must be a non-empty string`);
+ }
+ return value.trim();
+}
+
+function daysBetween(firstDate, secondDate) {
+ const first = new Date(`${firstDate}T00:00:00.000Z`);
+ const second = new Date(`${secondDate}T00:00:00.000Z`);
+ return Math.round((second.getTime() - first.getTime()) / 86400000);
+}
+
+function indexById(items, field) {
+ const map = new Map();
+ for (const item of asArray(items, field)) {
+ map.set(asNonEmptyString(item.id, `${field}.id`), item);
+ }
+ return map;
+}
+
+function compareParameters(protocolStep, observedStep, experiment) {
+ const findings = [];
+ const expectedParams = protocolStep.parameters || {};
+ const observedParams = observedStep.observed || {};
+
+ for (const [name, rule] of Object.entries(expectedParams)) {
+ const observed = observedParams[name];
+ if (typeof observed !== "number") {
+ findings.push({
+ severity: "high",
+ code: "missing-parameter",
+ experimentId: experiment.id,
+ stepId: protocolStep.id,
+ message: `${name} was not recorded for ${protocolStep.label}`
+ });
+ continue;
+ }
+
+ const delta = Math.abs(observed - rule.expected);
+ if (delta > rule.tolerance) {
+ findings.push({
+ severity: delta > rule.tolerance * 2 ? "critical" : "high",
+ code: "parameter-out-of-tolerance",
+ experimentId: experiment.id,
+ stepId: protocolStep.id,
+ message: `${name}=${observed} deviates from ${rule.expected} by ${delta}, tolerance ${rule.tolerance}`
+ });
+ }
+ }
+
+ return findings;
+}
+
+function auditExperiment(protocol, experiment) {
+ const findings = [];
+ const observedSteps = indexById(experiment.steps || [], `${experiment.id}.steps`);
+
+ if (experiment.protocolVersion !== protocol.version) {
+ findings.push({
+ severity: "high",
+ code: "protocol-version-mismatch",
+ experimentId: experiment.id,
+ message: `experiment used ${experiment.protocolVersion}, current protocol is ${protocol.version}`
+ });
+ }
+
+ const calibrationDriftDays = daysBetween(protocol.calibrationValidUntil, experiment.performedAt);
+ if (calibrationDriftDays > 0) {
+ findings.push({
+ severity: calibrationDriftDays > 14 ? "critical" : "high",
+ code: "instrument-calibration-expired",
+ experimentId: experiment.id,
+ message: `${experiment.instrumentId} calibration expired ${calibrationDriftDays} days before run`
+ });
+ }
+
+ const allowedLots = new Set(protocol.allowedReagentLots || []);
+ for (const lot of experiment.reagentLots || []) {
+ if (!allowedLots.has(lot)) {
+ findings.push({
+ severity: "high",
+ code: "unauthorized-reagent-lot",
+ experimentId: experiment.id,
+ message: `${lot} is not approved for ${protocol.id}`
+ });
+ }
+ }
+
+ for (const step of protocol.requiredSteps || []) {
+ const observed = observedSteps.get(step.id);
+ if (step.required && !observed) {
+ findings.push({
+ severity: "critical",
+ code: "missing-required-step",
+ experimentId: experiment.id,
+ stepId: step.id,
+ message: `${step.label} was not observed`
+ });
+ continue;
+ }
+
+ if (observed) {
+ findings.push(...compareParameters(step, observed, experiment));
+ }
+ }
+
+ return findings;
+}
+
+function scoreFindings(findings) {
+ return Math.min(
+ 100,
+ findings.reduce((total, finding) => total + SEVERITY_POINTS[finding.severity], 0)
+ );
+}
+
+function buildEdgeActions(experiment, findings) {
+ const riskScore = scoreFindings(findings);
+ const action =
+ findings.some((finding) => finding.severity === "critical") || riskScore >= 70
+ ? "hold"
+ : riskScore >= 35
+ ? "review"
+ : "publish";
+
+ return (experiment.proposedEdges || []).map((edge) => ({
+ ...edge,
+ action,
+ riskScore,
+ reason:
+ action === "publish"
+ ? "protocol adherence evidence is sufficient"
+ : "protocol deviation evidence must be resolved before graph publication"
+ }));
+}
+
+function auditProtocolDeviationGraph(packet) {
+ const protocols = indexById(packet.protocols || [], "protocols");
+ const experiments = asArray(packet.experiments || [], "experiments");
+
+ const experimentReports = experiments.map((experiment) => {
+ const protocol = protocols.get(asNonEmptyString(experiment.protocolId, "experiment.protocolId"));
+ if (!protocol) {
+ const findings = [
+ {
+ severity: "critical",
+ code: "unknown-protocol",
+ experimentId: experiment.id,
+ message: `protocol ${experiment.protocolId} is not registered`
+ }
+ ];
+ return {
+ experimentId: experiment.id,
+ protocolId: experiment.protocolId,
+ riskScore: scoreFindings(findings),
+ decision: "hold",
+ findings,
+ edgeActions: buildEdgeActions(experiment, findings)
+ };
+ }
+
+ const findings = auditExperiment(protocol, experiment);
+ const riskScore = scoreFindings(findings);
+ const edgeActions = buildEdgeActions(experiment, findings);
+ const decision = edgeActions.some((edge) => edge.action === "hold")
+ ? "hold"
+ : edgeActions.some((edge) => edge.action === "review")
+ ? "review"
+ : "publish";
+
+ return {
+ experimentId: experiment.id,
+ protocolId: protocol.id,
+ riskScore,
+ decision,
+ findings,
+ edgeActions
+ };
+ });
+
+ const totals = experimentReports.reduce(
+ (acc, report) => {
+ acc.experiments += 1;
+ acc.findings += report.findings.length;
+ acc.holdEdges += report.edgeActions.filter((edge) => edge.action === "hold").length;
+ acc.reviewEdges += report.edgeActions.filter((edge) => edge.action === "review").length;
+ acc.publishEdges += report.edgeActions.filter((edge) => edge.action === "publish").length;
+ return acc;
+ },
+ { experiments: 0, findings: 0, holdEdges: 0, reviewEdges: 0, publishEdges: 0 }
+ );
+
+ return {
+ generatedAt: packet.generatedAt || new Date().toISOString(),
+ module: "protocol-deviation-graph-guard",
+ decision: totals.holdEdges > 0 ? "hold-unsafe-graph-edges" : "publish-safe-graph-edges",
+ totals,
+ experimentReports
+ };
+}
+
+function renderMarkdown(report) {
+ const lines = [
+ "# Protocol Deviation Graph Guard Report",
+ "",
+ `Generated: ${report.generatedAt}`,
+ `Decision: ${report.decision}`,
+ "",
+ "## Totals",
+ "",
+ `- Experiments audited: ${report.totals.experiments}`,
+ `- Findings: ${report.totals.findings}`,
+ `- Hold edges: ${report.totals.holdEdges}`,
+ `- Review edges: ${report.totals.reviewEdges}`,
+ `- Publish edges: ${report.totals.publishEdges}`,
+ "",
+ "## Experiment Decisions",
+ ""
+ ];
+
+ for (const experiment of report.experimentReports) {
+ lines.push(`### ${experiment.experimentId}`);
+ lines.push("");
+ lines.push(`- Protocol: ${experiment.protocolId}`);
+ lines.push(`- Risk score: ${experiment.riskScore}`);
+ lines.push(`- Decision: ${experiment.decision}`);
+ lines.push(`- Edge actions: ${experiment.edgeActions.map((edge) => `${edge.type}:${edge.action}`).join(", ")}`);
+ if (experiment.findings.length === 0) {
+ lines.push("- Findings: none");
+ } else {
+ for (const finding of experiment.findings) {
+ lines.push(`- [${finding.severity}] ${finding.code}: ${finding.message}`);
+ }
+ }
+ lines.push("");
+ }
+
+ lines.push("Synthetic data only. No private research data, payment systems, live graph services, or external APIs are used.");
+ return `${lines.join("\n")}\n`;
+}
+
+function renderSvg(report) {
+ const safe = report.totals.publishEdges;
+ const held = report.totals.holdEdges;
+ const findings = report.totals.findings;
+ return `
+`;
+}
+
+function writeReportArtifacts(report, outputDir) {
+ fs.mkdirSync(outputDir, { recursive: true });
+ const jsonPath = path.join(outputDir, "protocol-deviation-report.json");
+ const markdownPath = path.join(outputDir, "protocol-deviation-report.md");
+ const svgPath = path.join(outputDir, "summary.svg");
+ fs.writeFileSync(jsonPath, `${JSON.stringify(report, null, 2)}\n`);
+ fs.writeFileSync(markdownPath, renderMarkdown(report));
+ fs.writeFileSync(svgPath, renderSvg(report));
+ return { jsonPath, markdownPath, svgPath };
+}
+
+module.exports = {
+ auditProtocolDeviationGraph,
+ renderMarkdown,
+ renderSvg,
+ writeReportArtifacts
+};
diff --git a/protocol-deviation-graph-guard/package.json b/protocol-deviation-graph-guard/package.json
new file mode 100644
index 00000000..7678a9fe
--- /dev/null
+++ b/protocol-deviation-graph-guard/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "protocol-deviation-graph-guard",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Synthetic protocol deviation guard for SCIBASE scientific knowledge graph edges.",
+ "main": "index.js",
+ "scripts": {
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "python demo_video.py"
+ }
+}
diff --git a/protocol-deviation-graph-guard/reports/demo-script.txt b/protocol-deviation-graph-guard/reports/demo-script.txt
new file mode 100644
index 00000000..ee4ab8cc
--- /dev/null
+++ b/protocol-deviation-graph-guard/reports/demo-script.txt
@@ -0,0 +1,12 @@
+Protocol Deviation Graph Guard demo
+
+1. Load synthetic protocol and experiment graph packets.
+2. Compare observed experiment steps against required protocol steps and tolerances.
+3. Check protocol version, reagent lot, and instrument calibration evidence.
+4. Hold unsafe experiment-to-protocol and recommendation edges before publication.
+
+Decision: hold-unsafe-graph-edges
+Held edges: 3
+Publishable edges: 2
+
+Synthetic data only. No private studies, credentials, live graph services, or external APIs.
diff --git a/protocol-deviation-graph-guard/reports/demo.gif b/protocol-deviation-graph-guard/reports/demo.gif
new file mode 100644
index 00000000..542a8a6a
Binary files /dev/null and b/protocol-deviation-graph-guard/reports/demo.gif differ
diff --git a/protocol-deviation-graph-guard/reports/demo.mp4 b/protocol-deviation-graph-guard/reports/demo.mp4
new file mode 100644
index 00000000..aa50ac1c
Binary files /dev/null and b/protocol-deviation-graph-guard/reports/demo.mp4 differ
diff --git a/protocol-deviation-graph-guard/reports/protocol-deviation-report.json b/protocol-deviation-graph-guard/reports/protocol-deviation-report.json
new file mode 100644
index 00000000..97909d04
--- /dev/null
+++ b/protocol-deviation-graph-guard/reports/protocol-deviation-report.json
@@ -0,0 +1,119 @@
+{
+ "generatedAt": "2026-06-01T15:45:00.000Z",
+ "module": "protocol-deviation-graph-guard",
+ "decision": "hold-unsafe-graph-edges",
+ "totals": {
+ "experiments": 2,
+ "findings": 7,
+ "holdEdges": 3,
+ "reviewEdges": 0,
+ "publishEdges": 2
+ },
+ "experimentReports": [
+ {
+ "experimentId": "experiment:organoid-run-2026-05-18",
+ "protocolId": "protocol:crispr-neuro-042",
+ "riskScore": 0,
+ "decision": "publish",
+ "findings": [],
+ "edgeActions": [
+ {
+ "from": "dataset:10.5555/scibase.neuro.2026.18",
+ "to": "protocol:crispr-neuro-042",
+ "type": "followed-protocol",
+ "action": "publish",
+ "riskScore": 0,
+ "reason": "protocol adherence evidence is sufficient"
+ },
+ {
+ "from": "experiment:organoid-run-2026-05-18",
+ "to": "concept:neural-crispr-screen",
+ "type": "supports-concept",
+ "action": "publish",
+ "riskScore": 0,
+ "reason": "protocol adherence evidence is sufficient"
+ }
+ ]
+ },
+ {
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "protocolId": "protocol:crispr-neuro-042",
+ "riskScore": 100,
+ "decision": "hold",
+ "findings": [
+ {
+ "severity": "high",
+ "code": "protocol-version-mismatch",
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "message": "experiment used 2.0.0, current protocol is 2.1.0"
+ },
+ {
+ "severity": "high",
+ "code": "instrument-calibration-expired",
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "message": "sequencer:mini-ion-17 calibration expired 4 days before run"
+ },
+ {
+ "severity": "high",
+ "code": "unauthorized-reagent-lot",
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "message": "CAS9-Z99 is not approved for protocol:crispr-neuro-042"
+ },
+ {
+ "severity": "critical",
+ "code": "parameter-out-of-tolerance",
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "stepId": "step:guide-rna-qc",
+ "message": "minOnTargetScore=71 deviates from 82 by 11, tolerance 3"
+ },
+ {
+ "severity": "critical",
+ "code": "parameter-out-of-tolerance",
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "stepId": "step:incubation",
+ "message": "hours=61 deviates from 48 by 13, tolerance 4"
+ },
+ {
+ "severity": "critical",
+ "code": "parameter-out-of-tolerance",
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "stepId": "step:incubation",
+ "message": "temperatureC=39.1 deviates from 37 by 2.1000000000000014, tolerance 0.5"
+ },
+ {
+ "severity": "critical",
+ "code": "missing-required-step",
+ "experimentId": "experiment:organoid-run-2026-05-29",
+ "stepId": "step:sequencing-qc",
+ "message": "Amplicon sequencing QC was not observed"
+ }
+ ],
+ "edgeActions": [
+ {
+ "from": "dataset:10.5555/scibase.neuro.2026.29",
+ "to": "protocol:crispr-neuro-042",
+ "type": "followed-protocol",
+ "action": "hold",
+ "riskScore": 100,
+ "reason": "protocol deviation evidence must be resolved before graph publication"
+ },
+ {
+ "from": "experiment:organoid-run-2026-05-29",
+ "to": "concept:neural-crispr-screen",
+ "type": "supports-concept",
+ "action": "hold",
+ "riskScore": 100,
+ "reason": "protocol deviation evidence must be resolved before graph publication"
+ },
+ {
+ "from": "experiment:organoid-run-2026-05-29",
+ "to": "recommendation:reuse-in-neurodegeneration-review",
+ "type": "eligible-for-recommendation",
+ "action": "hold",
+ "riskScore": 100,
+ "reason": "protocol deviation evidence must be resolved before graph publication"
+ }
+ ]
+ }
+ ]
+}
diff --git a/protocol-deviation-graph-guard/reports/protocol-deviation-report.md b/protocol-deviation-graph-guard/reports/protocol-deviation-report.md
new file mode 100644
index 00000000..51f5af51
--- /dev/null
+++ b/protocol-deviation-graph-guard/reports/protocol-deviation-report.md
@@ -0,0 +1,38 @@
+# Protocol Deviation Graph Guard Report
+
+Generated: 2026-06-01T15:45:00.000Z
+Decision: hold-unsafe-graph-edges
+
+## Totals
+
+- Experiments audited: 2
+- Findings: 7
+- Hold edges: 3
+- Review edges: 0
+- Publish edges: 2
+
+## Experiment Decisions
+
+### experiment:organoid-run-2026-05-18
+
+- Protocol: protocol:crispr-neuro-042
+- Risk score: 0
+- Decision: publish
+- Edge actions: followed-protocol:publish, supports-concept:publish
+- Findings: none
+
+### experiment:organoid-run-2026-05-29
+
+- Protocol: protocol:crispr-neuro-042
+- Risk score: 100
+- Decision: hold
+- Edge actions: followed-protocol:hold, supports-concept:hold, eligible-for-recommendation:hold
+- [high] protocol-version-mismatch: experiment used 2.0.0, current protocol is 2.1.0
+- [high] instrument-calibration-expired: sequencer:mini-ion-17 calibration expired 4 days before run
+- [high] unauthorized-reagent-lot: CAS9-Z99 is not approved for protocol:crispr-neuro-042
+- [critical] parameter-out-of-tolerance: minOnTargetScore=71 deviates from 82 by 11, tolerance 3
+- [critical] parameter-out-of-tolerance: hours=61 deviates from 48 by 13, tolerance 4
+- [critical] parameter-out-of-tolerance: temperatureC=39.1 deviates from 37 by 2.1000000000000014, tolerance 0.5
+- [critical] missing-required-step: Amplicon sequencing QC was not observed
+
+Synthetic data only. No private research data, payment systems, live graph services, or external APIs are used.
diff --git a/protocol-deviation-graph-guard/reports/summary.svg b/protocol-deviation-graph-guard/reports/summary.svg
new file mode 100644
index 00000000..f505b195
--- /dev/null
+++ b/protocol-deviation-graph-guard/reports/summary.svg
@@ -0,0 +1,15 @@
+
diff --git a/protocol-deviation-graph-guard/requirements-map.md b/protocol-deviation-graph-guard/requirements-map.md
new file mode 100644
index 00000000..188e0f82
--- /dev/null
+++ b/protocol-deviation-graph-guard/requirements-map.md
@@ -0,0 +1,13 @@
+# Requirements Map
+
+Issue #17 asks for Scientific Knowledge Graph Integration: entity extraction, knowledge navigation, recommendation support, and safe linked graph metadata.
+
+This slice covers a distinct graph-publication control:
+
+- **Entity and relationship quality**: validates protocol, experiment, dataset, and recommendation edges before publication.
+- **Knowledge navigation safety**: holds edges where a dataset or experiment claims to follow a protocol but evidence shows missing steps, expired calibration, lot mismatch, or incompatible protocol version.
+- **Recommendation safety**: blocks recommendation edges when protocol deviations could make downstream reuse misleading.
+- **Schema-style metadata readiness**: emits deterministic JSON and SVG reviewer artifacts that describe graph edge actions and reasons.
+- **Reviewability**: includes tests, synthetic sample data, Markdown reports, and a demo video.
+
+Non-overlap: this is not a broad extractor, ontology drift, multilingual alias, temporal consistency, geospatial sample provenance, sample custody/cold-chain, software dependency, image metadata, funding provenance, relationship conflict, or measurement harmonization slice. It focuses only on experiment-to-protocol adherence and graph edge hold/release actions.
diff --git a/protocol-deviation-graph-guard/sample-data.js b/protocol-deviation-graph-guard/sample-data.js
new file mode 100644
index 00000000..bdd06804
--- /dev/null
+++ b/protocol-deviation-graph-guard/sample-data.js
@@ -0,0 +1,106 @@
+const samplePacket = {
+ generatedAt: "2026-06-01T15:45:00.000Z",
+ protocols: [
+ {
+ id: "protocol:crispr-neuro-042",
+ version: "2.1.0",
+ title: "CRISPR neuron organoid perturbation assay",
+ calibrationValidUntil: "2026-05-25",
+ allowedReagentLots: ["CAS9-A17", "GUIDE-44B", "MATRIX-9"],
+ requiredSteps: [
+ {
+ id: "step:cell-line-auth",
+ label: "Cell-line identity authentication",
+ required: true
+ },
+ {
+ id: "step:guide-rna-qc",
+ label: "Guide RNA QC",
+ required: true,
+ parameters: {
+ minOnTargetScore: { expected: 82, tolerance: 3 }
+ }
+ },
+ {
+ id: "step:incubation",
+ label: "Post-edit incubation",
+ required: true,
+ parameters: {
+ hours: { expected: 48, tolerance: 4 },
+ temperatureC: { expected: 37, tolerance: 0.5 }
+ }
+ },
+ {
+ id: "step:sequencing-qc",
+ label: "Amplicon sequencing QC",
+ required: true,
+ parameters: {
+ minReadDepth: { expected: 12000, tolerance: 500 }
+ }
+ }
+ ]
+ }
+ ],
+ experiments: [
+ {
+ id: "experiment:organoid-run-2026-05-18",
+ protocolId: "protocol:crispr-neuro-042",
+ protocolVersion: "2.1.0",
+ performedAt: "2026-05-18",
+ datasetDoi: "10.5555/scibase.neuro.2026.18",
+ instrumentId: "sequencer:mini-ion-17",
+ reagentLots: ["CAS9-A17", "GUIDE-44B", "MATRIX-9"],
+ steps: [
+ { id: "step:cell-line-auth", observed: {} },
+ { id: "step:guide-rna-qc", observed: { minOnTargetScore: 84 } },
+ { id: "step:incubation", observed: { hours: 47, temperatureC: 37.2 } },
+ { id: "step:sequencing-qc", observed: { minReadDepth: 12380 } }
+ ],
+ proposedEdges: [
+ {
+ from: "dataset:10.5555/scibase.neuro.2026.18",
+ to: "protocol:crispr-neuro-042",
+ type: "followed-protocol"
+ },
+ {
+ from: "experiment:organoid-run-2026-05-18",
+ to: "concept:neural-crispr-screen",
+ type: "supports-concept"
+ }
+ ]
+ },
+ {
+ id: "experiment:organoid-run-2026-05-29",
+ protocolId: "protocol:crispr-neuro-042",
+ protocolVersion: "2.0.0",
+ performedAt: "2026-05-29",
+ datasetDoi: "10.5555/scibase.neuro.2026.29",
+ instrumentId: "sequencer:mini-ion-17",
+ reagentLots: ["CAS9-Z99", "GUIDE-44B"],
+ steps: [
+ { id: "step:cell-line-auth", observed: {} },
+ { id: "step:guide-rna-qc", observed: { minOnTargetScore: 71 } },
+ { id: "step:incubation", observed: { hours: 61, temperatureC: 39.1 } }
+ ],
+ proposedEdges: [
+ {
+ from: "dataset:10.5555/scibase.neuro.2026.29",
+ to: "protocol:crispr-neuro-042",
+ type: "followed-protocol"
+ },
+ {
+ from: "experiment:organoid-run-2026-05-29",
+ to: "concept:neural-crispr-screen",
+ type: "supports-concept"
+ },
+ {
+ from: "experiment:organoid-run-2026-05-29",
+ to: "recommendation:reuse-in-neurodegeneration-review",
+ type: "eligible-for-recommendation"
+ }
+ ]
+ }
+ ]
+};
+
+module.exports = { samplePacket };
diff --git a/protocol-deviation-graph-guard/test.js b/protocol-deviation-graph-guard/test.js
new file mode 100644
index 00000000..97c89ea6
--- /dev/null
+++ b/protocol-deviation-graph-guard/test.js
@@ -0,0 +1,55 @@
+const assert = require("assert");
+const { samplePacket } = require("./sample-data");
+const { auditProtocolDeviationGraph, renderMarkdown, renderSvg } = require("./index");
+
+const report = auditProtocolDeviationGraph(samplePacket);
+
+assert.strictEqual(report.module, "protocol-deviation-graph-guard");
+assert.strictEqual(report.totals.experiments, 2);
+assert.strictEqual(report.totals.publishEdges, 2);
+assert.strictEqual(report.totals.holdEdges, 3);
+assert.strictEqual(report.decision, "hold-unsafe-graph-edges");
+
+const safeExperiment = report.experimentReports.find(
+ (item) => item.experimentId === "experiment:organoid-run-2026-05-18"
+);
+assert.ok(safeExperiment);
+assert.strictEqual(safeExperiment.decision, "publish");
+assert.deepStrictEqual(safeExperiment.findings, []);
+
+const riskyExperiment = report.experimentReports.find(
+ (item) => item.experimentId === "experiment:organoid-run-2026-05-29"
+);
+assert.ok(riskyExperiment);
+assert.strictEqual(riskyExperiment.decision, "hold");
+assert.ok(riskyExperiment.findings.some((finding) => finding.code === "missing-required-step"));
+assert.ok(riskyExperiment.findings.some((finding) => finding.code === "protocol-version-mismatch"));
+assert.ok(riskyExperiment.findings.some((finding) => finding.code === "unauthorized-reagent-lot"));
+assert.ok(riskyExperiment.findings.some((finding) => finding.code === "parameter-out-of-tolerance"));
+assert.ok(riskyExperiment.edgeActions.every((edge) => edge.action === "hold"));
+
+const markdown = renderMarkdown(report);
+assert.ok(markdown.includes("Protocol Deviation Graph Guard Report"));
+assert.ok(markdown.includes("hold-unsafe-graph-edges"));
+assert.ok(markdown.includes("Synthetic data only"));
+
+const svg = renderSvg(report);
+assert.ok(svg.includes("