diff --git a/instrument-calibration-graph-guard/README.md b/instrument-calibration-graph-guard/README.md new file mode 100644 index 00000000..db215b10 --- /dev/null +++ b/instrument-calibration-graph-guard/README.md @@ -0,0 +1,50 @@ +# Instrument Calibration Graph Guard + +This module adds a focused Scientific Knowledge Graph Integration slice for instrument calibration provenance. + +It answers: + +> Can an instrument/tool node and its experiment edges be published or used for recommendations without misleading researchers about calibration validity? + +## Scope + +This is distinct from broad entity extraction, ontology aliasing, sample custody, chemical identity, image metadata, software dependency, funding provenance, protocol-deviation, and temporal-validity slices. It focuses only on instrument/tool graph nodes and calibration certificate evidence. + +The guard checks: + +- instrument nodes have stable identifiers and serial/model metadata +- each public experiment edge points to a calibration certificate +- certificate hashes and issuers are present +- calibration validity covers the experiment timestamp +- certificates are not revoked +- measurement units match the instrument capability profile +- recommendation/publication edges are held when calibration evidence is missing or expired + +## Decisions + +- `PUBLISH_GRAPH_EDGE`: graph node and experiment edge are safe to publish. +- `HOLD_FOR_CURATOR`: evidence is incomplete or stale but recoverable. +- `BLOCK_RECOMMENDATION`: publishing would create a misleading recommendation or entity page. + +## Run + +```bash +npm test +npm run demo +``` + +Demo outputs are written to `artifacts/`: + +- `instrument-calibration-results.json` +- `instrument-calibration-report.md` +- `instrument-calibration-summary.svg` +- `instrument-calibration-demo.mp4` + +## Boundaries + +- synthetic graph packets only +- no lab system integration +- no private experiment data +- no external APIs +- no credentials +- no live compliance claims diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-demo.mp4 b/instrument-calibration-graph-guard/artifacts/instrument-calibration-demo.mp4 new file mode 100644 index 00000000..2416d53f Binary files /dev/null and b/instrument-calibration-graph-guard/artifacts/instrument-calibration-demo.mp4 differ diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-report.md b/instrument-calibration-graph-guard/artifacts/instrument-calibration-report.md new file mode 100644 index 00000000..985a0448 --- /dev/null +++ b/instrument-calibration-graph-guard/artifacts/instrument-calibration-report.md @@ -0,0 +1,59 @@ +# Instrument Calibration Graph Guard Report + +Generated: 2026-06-12T00:00:00Z + +## Summary + +- Blocking findings: 4 +- Review findings: 2 +- Passing findings: 12 + +## Instrument Decisions + +### instrument:mass-spec:orbitrap-0021 + +- Decision: PUBLISH_GRAPH_EDGE +- Instrument type: mass_spectrometer +- Certificate: cal-2026-ms-0021 +- Experiment edges: 1 + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | instrument_identity_ready | Instrument node has stable model, serial, type, and graph ID metadata. | No remediation required. | +| pass | calibration_certificate_present | Calibration certificate metadata and artifact hash are present. | No remediation required. | +| pass | calibration_current | Calibration certificate is current at review time. | No remediation required. | +| pass | experiment_edges_covered | All experiment edges fall inside the calibration validity window. | No remediation required. | +| pass | unit_compatibility_ready | Experiment measurement units match declared instrument capabilities. | No remediation required. | +| pass | recommendation_controls_ready | Recommendation targets can be held when calibration evidence is not publishable. | No remediation required. | + +### instrument:microscope:confocal-17 + +- Decision: HOLD_FOR_CURATOR +- Instrument type: confocal_microscope +- Certificate: cal-2025-micro-17 +- Experiment edges: 1 + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | instrument_identity_ready | Instrument node has stable model, serial, type, and graph ID metadata. | No remediation required. | +| pass | calibration_certificate_present | Calibration certificate metadata and artifact hash are present. | No remediation required. | +| review | calibration_expired | Calibration certificate expired at 2025-12-31T23:59:59.000Z. | Refresh calibration evidence or hold graph publication for affected instrument edges. | +| pass | experiment_edges_covered | All experiment edges fall inside the calibration validity window. | No remediation required. | +| pass | unit_compatibility_ready | Experiment measurement units match declared instrument capabilities. | No remediation required. | +| pass | recommendation_controls_ready | Recommendation targets can be held when calibration evidence is not publishable. | No remediation required. | + +### instrument:sequencer:seq-09 + +- Decision: BLOCK_RECOMMENDATION +- Instrument type: sequencer +- Certificate: cal-2026-seq-09 +- Experiment edges: 1 + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | instrument_identity_ready | Instrument node has stable model, serial, type, and graph ID metadata. | No remediation required. | +| block | calibration_certificate_missing | Calibration certificate evidence missing: artifactHash. | Attach calibration certificate ID, issuer, artifact hash, and validity dates. | +| block | calibration_revoked | Calibration certificate is revoked. | Remove the instrument from public recommendations until a valid replacement certificate is attached. | +| block | experiment_outside_calibration_window | Experiment edges outside calibration window: edge:rnaseq-run-19. | Split or hold experiment edges measured outside the calibration validity window. | +| review | unit_incompatible | Measurement units not declared for instrument capability: edge:rnaseq-run-19:cycles. | Normalize units or route the graph packet to a curator for capability review. | +| block | recommendation_without_hold_control | Recommendation targets need hold controls before publication: recommend:single-cell-pipeline. | Add hold controls so recommendation edges are suppressed when calibration evidence fails. | diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-results.json b/instrument-calibration-graph-guard/artifacts/instrument-calibration-results.json new file mode 100644 index 00000000..dd1eae9e --- /dev/null +++ b/instrument-calibration-graph-guard/artifacts/instrument-calibration-results.json @@ -0,0 +1,164 @@ +[ + { + "graphNodeId": "instrument:mass-spec:orbitrap-0021", + "decision": "PUBLISH_GRAPH_EDGE", + "checkedAt": "2026-06-12T00:00:00.000Z", + "summary": { + "blocks": 0, + "reviews": 0, + "passes": 6 + }, + "findings": [ + { + "code": "instrument_identity_ready", + "severity": "pass", + "message": "Instrument node has stable model, serial, type, and graph ID metadata.", + "remediation": "No remediation required." + }, + { + "code": "calibration_certificate_present", + "severity": "pass", + "message": "Calibration certificate metadata and artifact hash are present.", + "remediation": "No remediation required." + }, + { + "code": "calibration_current", + "severity": "pass", + "message": "Calibration certificate is current at review time.", + "remediation": "No remediation required." + }, + { + "code": "experiment_edges_covered", + "severity": "pass", + "message": "All experiment edges fall inside the calibration validity window.", + "remediation": "No remediation required." + }, + { + "code": "unit_compatibility_ready", + "severity": "pass", + "message": "Experiment measurement units match declared instrument capabilities.", + "remediation": "No remediation required." + }, + { + "code": "recommendation_controls_ready", + "severity": "pass", + "message": "Recommendation targets can be held when calibration evidence is not publishable.", + "remediation": "No remediation required." + } + ], + "audit": { + "instrumentType": "mass_spectrometer", + "certificateId": "cal-2026-ms-0021", + "experimentCount": 1, + "recommendationTargets": 1 + } + }, + { + "graphNodeId": "instrument:microscope:confocal-17", + "decision": "HOLD_FOR_CURATOR", + "checkedAt": "2026-06-12T00:00:00.000Z", + "summary": { + "blocks": 0, + "reviews": 1, + "passes": 5 + }, + "findings": [ + { + "code": "instrument_identity_ready", + "severity": "pass", + "message": "Instrument node has stable model, serial, type, and graph ID metadata.", + "remediation": "No remediation required." + }, + { + "code": "calibration_certificate_present", + "severity": "pass", + "message": "Calibration certificate metadata and artifact hash are present.", + "remediation": "No remediation required." + }, + { + "code": "calibration_expired", + "severity": "review", + "message": "Calibration certificate expired at 2025-12-31T23:59:59.000Z.", + "remediation": "Refresh calibration evidence or hold graph publication for affected instrument edges." + }, + { + "code": "experiment_edges_covered", + "severity": "pass", + "message": "All experiment edges fall inside the calibration validity window.", + "remediation": "No remediation required." + }, + { + "code": "unit_compatibility_ready", + "severity": "pass", + "message": "Experiment measurement units match declared instrument capabilities.", + "remediation": "No remediation required." + }, + { + "code": "recommendation_controls_ready", + "severity": "pass", + "message": "Recommendation targets can be held when calibration evidence is not publishable.", + "remediation": "No remediation required." + } + ], + "audit": { + "instrumentType": "confocal_microscope", + "certificateId": "cal-2025-micro-17", + "experimentCount": 1, + "recommendationTargets": 1 + } + }, + { + "graphNodeId": "instrument:sequencer:seq-09", + "decision": "BLOCK_RECOMMENDATION", + "checkedAt": "2026-06-12T00:00:00.000Z", + "summary": { + "blocks": 4, + "reviews": 1, + "passes": 1 + }, + "findings": [ + { + "code": "instrument_identity_ready", + "severity": "pass", + "message": "Instrument node has stable model, serial, type, and graph ID metadata.", + "remediation": "No remediation required." + }, + { + "code": "calibration_certificate_missing", + "severity": "block", + "message": "Calibration certificate evidence missing: artifactHash.", + "remediation": "Attach calibration certificate ID, issuer, artifact hash, and validity dates." + }, + { + "code": "calibration_revoked", + "severity": "block", + "message": "Calibration certificate is revoked.", + "remediation": "Remove the instrument from public recommendations until a valid replacement certificate is attached." + }, + { + "code": "experiment_outside_calibration_window", + "severity": "block", + "message": "Experiment edges outside calibration window: edge:rnaseq-run-19.", + "remediation": "Split or hold experiment edges measured outside the calibration validity window." + }, + { + "code": "unit_incompatible", + "severity": "review", + "message": "Measurement units not declared for instrument capability: edge:rnaseq-run-19:cycles.", + "remediation": "Normalize units or route the graph packet to a curator for capability review." + }, + { + "code": "recommendation_without_hold_control", + "severity": "block", + "message": "Recommendation targets need hold controls before publication: recommend:single-cell-pipeline.", + "remediation": "Add hold controls so recommendation edges are suppressed when calibration evidence fails." + } + ], + "audit": { + "instrumentType": "sequencer", + "certificateId": "cal-2026-seq-09", + "experimentCount": 1, + "recommendationTargets": 1 + } + } +] diff --git a/instrument-calibration-graph-guard/artifacts/instrument-calibration-summary.svg b/instrument-calibration-graph-guard/artifacts/instrument-calibration-summary.svg new file mode 100644 index 00000000..50ac0925 --- /dev/null +++ b/instrument-calibration-graph-guard/artifacts/instrument-calibration-summary.svg @@ -0,0 +1,23 @@ + + + Instrument Calibration Graph Guard + blocks=4 reviews=2 passes=12 + + instrument:mass-spec:orbitrap-0021 + + PUBLISH_GRAPH_EDGE + + instrument:microscope:confocal-17 + + HOLD_FOR_CURATOR + + instrument:sequencer:seq-09 + + BLOCK_RECOMMENDATION + + diff --git a/instrument-calibration-graph-guard/examples/instrument-graph-packets.json b/instrument-calibration-graph-guard/examples/instrument-graph-packets.json new file mode 100644 index 00000000..8f90e466 --- /dev/null +++ b/instrument-calibration-graph-guard/examples/instrument-graph-packets.json @@ -0,0 +1,101 @@ +[ + { + "instrument": { + "nodeId": "instrument:mass-spec:orbitrap-0021", + "type": "mass_spectrometer", + "model": "Orbitrap Exploris 480", + "serialNumber": "OE480-21", + "capabilityUnits": ["ppm", "m/z", "Da"] + }, + "calibration": { + "certificateId": "cal-2026-ms-0021", + "issuer": "SCIBASE Metrology Core", + "artifactHash": "sha256:26d78a1c174fd405f8f6d3d403cc6df1a4338a4c9fe1adf5e8c4b85e6f75be33", + "validFrom": "2026-01-01T00:00:00Z", + "validUntil": "2026-12-31T23:59:59Z", + "revoked": false + }, + "experimentEdges": [ + { + "edgeId": "edge:protein-atlas-run-77", + "projectId": "protein-atlas", + "measuredAt": "2026-04-12T10:15:00Z", + "measurementUnit": "ppm" + } + ], + "recommendationTargets": [ + { + "targetId": "recommend:similar-proteomics-workflows", + "usesInstrumentReliability": true, + "requiresCurrentCalibration": true, + "publicationMode": "holdable" + } + ] + }, + { + "instrument": { + "nodeId": "instrument:microscope:confocal-17", + "type": "confocal_microscope", + "model": "LSM 980", + "serialNumber": "LSM980-17", + "capabilityUnits": ["nm", "um", "pixel"] + }, + "calibration": { + "certificateId": "cal-2025-micro-17", + "issuer": "Imaging Core", + "artifactHash": "sha256:80dfe725cb5f2ed3ab2237ae6d8ac7d759956275961874cc47bdbf48c8562d5f", + "validFrom": "2025-01-01T00:00:00Z", + "validUntil": "2025-12-31T23:59:59Z", + "revoked": false + }, + "experimentEdges": [ + { + "edgeId": "edge:neuron-synapse-image-12", + "projectId": "synapse-map", + "measuredAt": "2025-10-15T09:30:00Z", + "measurementUnit": "nm" + } + ], + "recommendationTargets": [ + { + "targetId": "recommend:microscopy-protocols", + "usesInstrumentReliability": true, + "requiresCurrentCalibration": true, + "publicationMode": "holdable" + } + ] + }, + { + "instrument": { + "nodeId": "instrument:sequencer:seq-09", + "type": "sequencer", + "model": "NovaSeq X", + "serialNumber": "NSX-09", + "capabilityUnits": ["reads", "bp"] + }, + "calibration": { + "certificateId": "cal-2026-seq-09", + "issuer": "Genome Core", + "artifactHash": "", + "validFrom": "2026-01-01T00:00:00Z", + "validUntil": "2026-06-01T00:00:00Z", + "revoked": true + }, + "experimentEdges": [ + { + "edgeId": "edge:rnaseq-run-19", + "projectId": "immune-atlas", + "measuredAt": "2026-06-10T11:20:00Z", + "measurementUnit": "cycles" + } + ], + "recommendationTargets": [ + { + "targetId": "recommend:single-cell-pipeline", + "usesInstrumentReliability": true, + "requiresCurrentCalibration": true, + "publicationMode": "public" + } + ] + } +] diff --git a/instrument-calibration-graph-guard/package.json b/instrument-calibration-graph-guard/package.json new file mode 100644 index 00000000..5fa93dda --- /dev/null +++ b/instrument-calibration-graph-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "instrument-calibration-graph-guard", + "version": "1.0.0", + "description": "Knowledge graph guard for instrument calibration provenance before entity/recommendation publication.", + "type": "module", + "scripts": { + "test": "node test/instrumentCalibrationGraphGuard.test.js", + "demo": "node scripts/demo.js" + }, + "license": "MIT" +} diff --git a/instrument-calibration-graph-guard/scripts/demo.js b/instrument-calibration-graph-guard/scripts/demo.js new file mode 100644 index 00000000..78d7d173 --- /dev/null +++ b/instrument-calibration-graph-guard/scripts/demo.js @@ -0,0 +1,146 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { spawnSync } from "node:child_process" +import { join } from "node:path" +import { tmpdir } from "node:os" +import packets from "../examples/instrument-graph-packets.json" with { type: "json" } +import { evaluateInstrumentGraphBatch, summarize } from "../src/index.js" + +const ARTIFACT_DIR = new URL("../artifacts/", import.meta.url) +const now = "2026-06-12T00:00:00Z" +const results = evaluateInstrumentGraphBatch(packets, { now }) +const summary = summarize(results) + +await mkdir(ARTIFACT_DIR, { recursive: true }) +await writeFile(new URL("instrument-calibration-results.json", ARTIFACT_DIR), `${JSON.stringify(results, null, 2)}\n`) +await writeFile(new URL("instrument-calibration-report.md", ARTIFACT_DIR), renderReport(results, summary)) +await writeFile(new URL("instrument-calibration-summary.svg", ARTIFACT_DIR), renderSvg(results, summary)) +await renderMp4() + +console.log("Instrument calibration graph guard demo generated") +console.log(`- decisions: ${results.map((result) => `${result.graphNodeId}:${result.decision}`).join(", ")}`) +console.log(`- summary: ${JSON.stringify(summary)}`) + +function renderReport(results, summary) { + const lines = [ + "# Instrument Calibration Graph Guard Report", + "", + `Generated: ${now}`, + "", + "## Summary", + "", + `- Blocking findings: ${summary.blocks}`, + `- Review findings: ${summary.reviews}`, + `- Passing findings: ${summary.passes}`, + "", + "## Instrument Decisions", + "" + ] + + for (const result of results) { + lines.push(`### ${result.graphNodeId}`) + lines.push("") + lines.push(`- Decision: ${result.decision}`) + lines.push(`- Instrument type: ${result.audit.instrumentType}`) + lines.push(`- Certificate: ${result.audit.certificateId ?? "none"}`) + lines.push(`- Experiment edges: ${result.audit.experimentCount}`) + lines.push("") + lines.push("| Severity | Code | Message | Remediation |") + lines.push("| --- | --- | --- | --- |") + for (const finding of result.findings) { + lines.push(`| ${finding.severity} | ${finding.code} | ${finding.message} | ${finding.remediation} |`) + } + lines.push("") + } + + return `${lines.join("\n")}\n` +} + +function renderSvg(results, summary) { + const rows = results.map((result, index) => { + const y = 126 + index * 66 + const color = result.decision === "PUBLISH_GRAPH_EDGE" ? "#15803d" : result.decision === "HOLD_FOR_CURATOR" ? "#b45309" : "#b91c1c" + return ` + ${escapeXml(result.graphNodeId)} + + ${result.decision}` + }).join("\n") + + return ` + + Instrument Calibration Graph Guard + blocks=${summary.blocks} reviews=${summary.reviews} passes=${summary.passes} + ${rows} + + +` +} + +async function renderMp4() { + const ppmPath = join(tmpdir(), `instrument-calibration-frame-${Date.now()}.ppm`) + const mp4Path = new URL("instrument-calibration-demo.mp4", ARTIFACT_DIR) + await writeFile(ppmPath, renderPpmFrame()) + + const ffmpeg = spawnSync("ffmpeg", [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-loop", + "1", + "-framerate", + "1", + "-i", + ppmPath, + "-t", + "5", + "-pix_fmt", + "yuv420p", + mp4Path.pathname + ], { encoding: "utf8" }) + + if (ffmpeg.status !== 0) { + await writeFile(new URL("instrument-calibration-demo-fallback.txt", ARTIFACT_DIR), ffmpeg.stderr || "ffmpeg failed") + } +} + +function renderPpmFrame() { + const width = 920 + const height = 360 + const pixels = Buffer.alloc(width * height * 3, 248) + + fillRect(pixels, width, 0, 0, width, height, [248, 250, 252]) + fillRect(pixels, width, 40, 48, 840, 38, [15, 23, 42]) + fillRect(pixels, width, 40, 112, 250, 72, [21, 128, 61]) + fillRect(pixels, width, 335, 112, 250, 72, [180, 83, 9]) + fillRect(pixels, width, 630, 112, 250, 72, [185, 28, 28]) + fillRect(pixels, width, 40, 226, 840, 54, [226, 232, 240]) + fillRect(pixels, width, 40, 304, 250, 18, [21, 128, 61]) + fillRect(pixels, width, 335, 304, 250, 18, [180, 83, 9]) + fillRect(pixels, width, 630, 304, 250, 18, [185, 28, 28]) + + const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii") + return Buffer.concat([header, pixels]) +} + +function fillRect(pixels, imageWidth, x, y, width, height, rgb) { + for (let row = y; row < y + height; row += 1) { + for (let col = x; col < x + width; col += 1) { + const offset = (row * imageWidth + col) * 3 + pixels[offset] = rgb[0] + pixels[offset + 1] = rgb[1] + pixels[offset + 2] = rgb[2] + } + } +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") +} diff --git a/instrument-calibration-graph-guard/src/index.js b/instrument-calibration-graph-guard/src/index.js new file mode 100644 index 00000000..71f1c344 --- /dev/null +++ b/instrument-calibration-graph-guard/src/index.js @@ -0,0 +1,189 @@ +export function evaluateInstrumentGraphPacket(packet, options = {}) { + const now = normalizeDate(options.now ?? new Date()) + const findings = [] + + checkInstrumentIdentity(packet, findings) + checkCertificatePresence(packet, findings) + checkCertificateValidity(packet, findings, now) + checkExperimentCoverage(packet, findings) + checkUnitCompatibility(packet, findings) + checkRecommendationSafety(packet, findings) + + const blocks = findings.filter((finding) => finding.severity === "block") + const reviews = findings.filter((finding) => finding.severity === "review") + + const decision = blocks.length + ? "BLOCK_RECOMMENDATION" + : reviews.length + ? "HOLD_FOR_CURATOR" + : "PUBLISH_GRAPH_EDGE" + + return { + graphNodeId: packet.instrument?.nodeId ?? "unknown", + decision, + checkedAt: now.toISOString(), + summary: summarize(findings), + findings, + audit: { + instrumentType: packet.instrument?.type ?? "unknown", + certificateId: packet.calibration?.certificateId ?? null, + experimentCount: packet.experimentEdges?.length ?? 0, + recommendationTargets: packet.recommendationTargets?.length ?? 0 + } + } +} + +export function evaluateInstrumentGraphBatch(packets, options = {}) { + return packets.map((packet) => evaluateInstrumentGraphPacket(packet, options)) +} + +export function summarize(resultsOrFindings) { + const findings = Array.isArray(resultsOrFindings) + ? resultsOrFindings.flatMap((item) => item.findings ?? item) + : [] + + return { + blocks: findings.filter((finding) => finding.severity === "block").length, + reviews: findings.filter((finding) => finding.severity === "review").length, + passes: findings.filter((finding) => finding.severity === "pass").length + } +} + +function checkInstrumentIdentity(packet, findings) { + const instrument = packet.instrument ?? {} + const missing = ["nodeId", "model", "serialNumber", "type"].filter((field) => !instrument[field]) + + if (missing.length) { + findings.push(issue("instrument_identity_incomplete", "block", `Instrument identity missing: ${missing.join(", ")}.`)) + return + } + + findings.push(issue("instrument_identity_ready", "pass", "Instrument node has stable model, serial, type, and graph ID metadata.")) +} + +function checkCertificatePresence(packet, findings) { + const calibration = packet.calibration ?? {} + const missing = ["certificateId", "issuer", "artifactHash", "validFrom", "validUntil"].filter((field) => !calibration[field]) + + if (missing.length) { + findings.push(issue("calibration_certificate_missing", "block", `Calibration certificate evidence missing: ${missing.join(", ")}.`)) + return + } + + findings.push(issue("calibration_certificate_present", "pass", "Calibration certificate metadata and artifact hash are present.")) +} + +function checkCertificateValidity(packet, findings, now) { + const calibration = packet.calibration ?? {} + if (calibration.revoked === true) { + findings.push(issue("calibration_revoked", "block", "Calibration certificate is revoked.")) + return + } + + const validFrom = parseOptionalDate(calibration.validFrom) + const validUntil = parseOptionalDate(calibration.validUntil) + if (!validFrom || !validUntil) return + + if (validUntil < now) { + findings.push(issue("calibration_expired", "review", `Calibration certificate expired at ${validUntil.toISOString()}.`)) + return + } + + findings.push(issue("calibration_current", "pass", "Calibration certificate is current at review time.")) +} + +function checkExperimentCoverage(packet, findings) { + const calibration = packet.calibration ?? {} + const validFrom = parseOptionalDate(calibration.validFrom) + const validUntil = parseOptionalDate(calibration.validUntil) + const edges = packet.experimentEdges ?? [] + + if (!edges.length) { + findings.push(issue("experiment_edges_missing", "review", "No experiment edges are attached to the instrument node.")) + return + } + + const uncovered = edges.filter((edge) => { + const measuredAt = parseOptionalDate(edge.measuredAt) + return !measuredAt || !validFrom || !validUntil || measuredAt < validFrom || measuredAt > validUntil + }) + + if (uncovered.length) { + findings.push(issue("experiment_outside_calibration_window", "block", `Experiment edges outside calibration window: ${uncovered.map((edge) => edge.edgeId).join(", ")}.`)) + return + } + + findings.push(issue("experiment_edges_covered", "pass", "All experiment edges fall inside the calibration validity window.")) +} + +function checkUnitCompatibility(packet, findings) { + const allowedUnits = new Set(packet.instrument?.capabilityUnits ?? []) + const incompatible = (packet.experimentEdges ?? []).filter((edge) => !allowedUnits.has(edge.measurementUnit)) + + if (allowedUnits.size === 0) { + findings.push(issue("capability_units_missing", "review", "Instrument capability units are missing.")) + return + } + + if (incompatible.length) { + findings.push(issue("unit_incompatible", "review", `Measurement units not declared for instrument capability: ${incompatible.map((edge) => `${edge.edgeId}:${edge.measurementUnit}`).join(", ")}.`)) + return + } + + findings.push(issue("unit_compatibility_ready", "pass", "Experiment measurement units match declared instrument capabilities.")) +} + +function checkRecommendationSafety(packet, findings) { + const targets = packet.recommendationTargets ?? [] + if (!targets.length) { + findings.push(issue("recommendation_targets_missing", "review", "No recommendation targets are attached for graph navigation.")) + return + } + + const unsafe = targets.filter((target) => target.usesInstrumentReliability === true && target.requiresCurrentCalibration === true && target.publicationMode !== "holdable") + if (unsafe.length) { + findings.push(issue("recommendation_without_hold_control", "block", `Recommendation targets need hold controls before publication: ${unsafe.map((target) => target.targetId).join(", ")}.`)) + return + } + + findings.push(issue("recommendation_controls_ready", "pass", "Recommendation targets can be held when calibration evidence is not publishable.")) +} + +function issue(code, severity, message) { + return { + code, + severity, + message, + remediation: remediationFor(code) + } +} + +function remediationFor(code) { + const remediations = { + instrument_identity_incomplete: "Add stable instrument node ID, type, model, and serial metadata before graph publication.", + calibration_certificate_missing: "Attach calibration certificate ID, issuer, artifact hash, and validity dates.", + calibration_revoked: "Remove the instrument from public recommendations until a valid replacement certificate is attached.", + calibration_expired: "Refresh calibration evidence or hold graph publication for affected instrument edges.", + experiment_edges_missing: "Attach at least one experiment edge or suppress empty instrument pages.", + experiment_outside_calibration_window: "Split or hold experiment edges measured outside the calibration validity window.", + capability_units_missing: "Declare instrument capability units before enabling unit-sensitive recommendations.", + unit_incompatible: "Normalize units or route the graph packet to a curator for capability review.", + recommendation_targets_missing: "Attach intended recommendation targets or mark the node as navigation-only.", + recommendation_without_hold_control: "Add hold controls so recommendation edges are suppressed when calibration evidence fails." + } + + return remediations[code] ?? "No remediation required." +} + +function normalizeDate(value) { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date: ${value}`) + } + return date +} + +function parseOptionalDate(value) { + if (!value) return null + return normalizeDate(value) +} diff --git a/instrument-calibration-graph-guard/test/instrumentCalibrationGraphGuard.test.js b/instrument-calibration-graph-guard/test/instrumentCalibrationGraphGuard.test.js new file mode 100644 index 00000000..91790712 --- /dev/null +++ b/instrument-calibration-graph-guard/test/instrumentCalibrationGraphGuard.test.js @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import packets from "../examples/instrument-graph-packets.json" with { type: "json" } +import { evaluateInstrumentGraphBatch, evaluateInstrumentGraphPacket, summarize } from "../src/index.js" + +const now = "2026-06-12T00:00:00Z" +const [ready, hold, blocked] = evaluateInstrumentGraphBatch(packets, { now }) + +assert.equal(ready.decision, "PUBLISH_GRAPH_EDGE") +assert.equal(ready.summary.blocks, 0) +assert.equal(ready.summary.reviews, 0) +assert.ok(ready.findings.some((finding) => finding.code === "calibration_current")) + +assert.equal(hold.decision, "HOLD_FOR_CURATOR") +assert.ok(hold.findings.some((finding) => finding.code === "calibration_expired")) +assert.equal(hold.summary.blocks, 0) + +assert.equal(blocked.decision, "BLOCK_RECOMMENDATION") +assert.ok(blocked.findings.some((finding) => finding.code === "calibration_certificate_missing")) +assert.ok(blocked.findings.some((finding) => finding.code === "calibration_revoked")) +assert.ok(blocked.findings.some((finding) => finding.code === "experiment_outside_calibration_window")) +assert.ok(blocked.findings.some((finding) => finding.code === "recommendation_without_hold_control")) + +const missingIdentity = structuredClone(packets[0]) +delete missingIdentity.instrument.serialNumber +const missingIdentityResult = evaluateInstrumentGraphPacket(missingIdentity, { now }) +assert.equal(missingIdentityResult.decision, "BLOCK_RECOMMENDATION") +assert.ok(missingIdentityResult.findings.some((finding) => finding.code === "instrument_identity_incomplete")) + +const noTargets = structuredClone(packets[0]) +noTargets.recommendationTargets = [] +const noTargetsResult = evaluateInstrumentGraphPacket(noTargets, { now }) +assert.equal(noTargetsResult.decision, "HOLD_FOR_CURATOR") +assert.ok(noTargetsResult.findings.some((finding) => finding.code === "recommendation_targets_missing")) + +const batchSummary = summarize([ready, hold, blocked]) +assert.equal(batchSummary.blocks, blocked.summary.blocks) +assert.equal(batchSummary.reviews, hold.summary.reviews + blocked.summary.reviews) + +console.log("instrument calibration graph guard tests passed")