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 ` + + Protocol Deviation Graph Guard + Recommendation-safe graph publication control for SCIBASE #17 + + ${safe} + publishable edges + + ${held} + held graph edges + + ${findings} + protocol findings + Synthetic reviewer artifact. No live graph, credentials, private studies, or external APIs. + +`; +} + +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 @@ + + + Protocol Deviation Graph Guard + Recommendation-safe graph publication control for SCIBASE #17 + + 2 + publishable edges + + 3 + held graph edges + + 7 + protocol findings + Synthetic reviewer artifact. No live graph, credentials, private studies, or external APIs. + 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("