diff --git a/README.md b/README.md index d338cf6..515e0cb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # deepevents.ai deepevents.ai main codebase + +- `instrument-method-compatibility-graph/` adds instrument, method, and dataset compatibility checks for scientific knowledge graphs. diff --git a/instrument-method-compatibility-graph/README.md b/instrument-method-compatibility-graph/README.md new file mode 100644 index 0000000..1b7b4bb --- /dev/null +++ b/instrument-method-compatibility-graph/README.md @@ -0,0 +1,39 @@ +# Instrument Method Compatibility Graph + +This module adds a focused knowledge graph slice for instrument, method, and dataset compatibility. + +It covers: + +- typed instrument, method, dataset, and experiment nodes +- compatibility edges across instruments, methods, and datasets +- modality, file format, resolution, calibration, and evidence checks +- entity pages for instrument, method, and dataset navigation +- candidate edge recommendations for graph discovery +- curator actions, audit events, and deterministic digests + +The implementation is dependency-free and uses synthetic sample data only. + +## Run + +```bash +npm run check +npm test +npm run demo +``` + +## Demo Assets + +- short demo video: `docs/demo.webm` +- `docs/demo.svg` + +## API + +```js +import { + evaluateInstrumentMethodCompatibility, + renderInstrumentMethodCompatibilityReport +} from "./src/instrument-method-compatibility-graph.js"; + +const result = evaluateInstrumentMethodCompatibility(input); +console.log(renderInstrumentMethodCompatibilityReport(result)); +``` diff --git a/instrument-method-compatibility-graph/data/sample-graph-input.json b/instrument-method-compatibility-graph/data/sample-graph-input.json new file mode 100644 index 0000000..a289a47 --- /dev/null +++ b/instrument-method-compatibility-graph/data/sample-graph-input.json @@ -0,0 +1,89 @@ +{ + "generatedAt": "2026-05-16T17:10:00Z", + "project": { + "id": "kg-neuroimmune-221", + "title": "Neuroimmune Knowledge Map", + "domain": "neuroscience" + }, + "instruments": [ + { + "id": "inst-confocal-7", + "name": "Confocal Microscope 7", + "modalities": ["imaging"], + "supportedMethodIds": ["method-cell-segmentation", "method-colocalization"], + "calibrationDate": "2026-04-22T00:00:00Z" + }, + { + "id": "inst-flow-alpha", + "name": "Flow Cytometer Alpha", + "modalities": ["single-cell"], + "supportedMethodIds": ["method-cell-count"], + "calibrationDate": "2025-11-15T00:00:00Z" + } + ], + "methods": [ + { + "id": "method-cell-segmentation", + "name": "Cell Segmentation", + "status": "active", + "requiredModalities": ["imaging"], + "acceptedOutputs": ["ome-tiff", "tiff"], + "maxResolutionMicrons": 0.8, + "maxCalibrationAgeDays": 60 + }, + { + "id": "method-legacy-clustering", + "name": "Legacy Clustering", + "status": "deprecated", + "requiredModalities": ["single-cell"], + "acceptedOutputs": ["fcs"], + "maxCalibrationAgeDays": 120 + } + ], + "datasets": [ + { + "id": "data-glia-images", + "name": "Glia marker image stack", + "modality": "imaging", + "format": "ome-tiff", + "resolutionMicrons": 0.5 + }, + { + "id": "data-cytokine-fcs", + "name": "Cytokine panel FCS", + "modality": "single-cell", + "format": "fcs", + "resolutionMicrons": 1.2 + } + ], + "experiments": [ + { + "id": "exp-segmentation-01", + "instrumentId": "inst-confocal-7", + "methodId": "method-cell-segmentation", + "datasetId": "data-glia-images", + "evidence": [ + { + "id": "ev-segmentation-validation", + "type": "validation", + "status": "ready", + "quality": 0.91 + } + ] + }, + { + "id": "exp-legacy-flow-02", + "instrumentId": "inst-flow-alpha", + "methodId": "method-legacy-clustering", + "datasetId": "data-cytokine-fcs", + "evidence": [ + { + "id": "ev-flow-draft", + "type": "notebook", + "status": "draft", + "quality": 0.44 + } + ] + } + ] +} diff --git a/instrument-method-compatibility-graph/docs/demo.svg b/instrument-method-compatibility-graph/docs/demo.svg new file mode 100644 index 0000000..bde0b55 --- /dev/null +++ b/instrument-method-compatibility-graph/docs/demo.svg @@ -0,0 +1,20 @@ + + Instrument method compatibility demo + Terminal-style demo output for the instrument method compatibility graph. + + + + + + + Instrument Method Compatibility + Neuroimmune Knowledge Map: blocked (55/100) + Edges ready/review: 1/1 + Findings high/medium/low: 1/3/0 + Manifest: 4e5e12a454a2e68a + Curator actions: + - high method_deprecated: Pick a supported method or add curator approval for legacy use. + - medium calibration_out_of_window: Attach fresh calibration evidence before using this edge in recommendations. + - medium compatibility_evidence_weak: Add validation evidence before surfacing the relationship to researchers. + - medium instrument_method_not_listed: Confirm the instrument-method edge or choose a supported instrument. + diff --git a/instrument-method-compatibility-graph/docs/demo.webm b/instrument-method-compatibility-graph/docs/demo.webm new file mode 100644 index 0000000..374c084 Binary files /dev/null and b/instrument-method-compatibility-graph/docs/demo.webm differ diff --git a/instrument-method-compatibility-graph/docs/requirement-map.md b/instrument-method-compatibility-graph/docs/requirement-map.md new file mode 100644 index 0000000..d8f9a2b --- /dev/null +++ b/instrument-method-compatibility-graph/docs/requirement-map.md @@ -0,0 +1,11 @@ +# Requirement Map + +This slice targets issue #17's graph navigation and recommendation requirements. + +| Requirement | Coverage | +| --- | --- | +| Entity extraction / linked data | Models typed instrument, method, dataset, and experiment nodes with stable ids. | +| Knowledge navigation | Builds entity pages and graph query labels for instrument-to-method-to-dataset traversal. | +| Relationship quality | Scores compatibility edges and flags modality, format, resolution, calibration, evidence, and deprecated-method issues. | +| Recommendations | Emits candidate graph edges when instrument, method, and dataset metadata are compatible. | +| Tests | `npm test` covers ready edges, missing nodes, mismatch blockers, stale calibration, weak evidence, recommendations, digests, and reports. | diff --git a/instrument-method-compatibility-graph/package.json b/instrument-method-compatibility-graph/package.json new file mode 100644 index 0000000..d0124e8 --- /dev/null +++ b/instrument-method-compatibility-graph/package.json @@ -0,0 +1,15 @@ +{ + "name": "instrument-method-compatibility-graph", + "version": "1.0.0", + "description": "Deterministic instrument and method compatibility graph checks for research projects.", + "type": "module", + "scripts": { + "check": "node --check src/instrument-method-compatibility-graph.js && node --check scripts/demo.js && node --check test/instrument-method-compatibility-graph.test.js", + "demo": "node scripts/demo.js", + "test": "node --test test/*.test.js" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/instrument-method-compatibility-graph/scripts/demo.js b/instrument-method-compatibility-graph/scripts/demo.js new file mode 100644 index 0000000..d3e8b58 --- /dev/null +++ b/instrument-method-compatibility-graph/scripts/demo.js @@ -0,0 +1,14 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + evaluateInstrumentMethodCompatibility, + renderInstrumentMethodCompatibilityReport +} from "../src/instrument-method-compatibility-graph.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const inputPath = path.join(__dirname, "..", "data", "sample-graph-input.json"); +const input = JSON.parse(fs.readFileSync(inputPath, "utf8")); + +const result = evaluateInstrumentMethodCompatibility(input); +console.log(renderInstrumentMethodCompatibilityReport(result)); diff --git a/instrument-method-compatibility-graph/src/instrument-method-compatibility-graph.js b/instrument-method-compatibility-graph/src/instrument-method-compatibility-graph.js new file mode 100644 index 0000000..a3f9cc5 --- /dev/null +++ b/instrument-method-compatibility-graph/src/instrument-method-compatibility-graph.js @@ -0,0 +1,527 @@ +import crypto from "node:crypto"; + +const SEVERITY_WEIGHT = { + high: 18, + medium: 9, + low: 4 +}; + +const SEVERITY_RANK = { + high: 0, + medium: 1, + low: 2 +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function asObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function normalize(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " "); +} + +function parseNumber(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + +function parseDate(value) { + if (typeof value !== "string" || value.trim() === "") { + return null; + } + + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp) : null; +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + + return JSON.stringify(value); +} + +function digest(value, length = 16) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex").slice(0, length); +} + +function indexById(items) { + return new Map( + asArray(items) + .map(asObject) + .map((item) => [item.id, item]) + .filter(([id]) => Boolean(id)) + ); +} + +function normalizedSet(values) { + return new Set(asArray(values).map(normalize).filter(Boolean)); +} + +function intersects(left, right) { + for (const value of left) { + if (right.has(value)) { + return true; + } + } + return false; +} + +function addFinding(findings, experiment, severity, code, message, action, metadata = {}) { + findings.push({ + experimentId: experiment.id ?? "unlabeled-experiment", + severity, + code, + message, + action, + ...metadata + }); +} + +function addNodeMissing(findings, experiment, type, id) { + addFinding( + findings, + experiment, + "high", + `${type}_node_missing`, + `The ${type} node ${id ?? "unknown"} is not present in the graph.`, + `Add the ${type} node or remove the stale experiment edge.`, + { nodeType: type, nodeId: id } + ); +} + +function calibrationAgeDays(instrument, generatedAt) { + const generatedDate = parseDate(generatedAt); + const calibrationDate = parseDate(instrument.calibrationDate); + if (!generatedDate || !calibrationDate) { + return null; + } + + return Math.floor((generatedDate.getTime() - calibrationDate.getTime()) / DAY_MS); +} + +function bestEvidence(evidenceItems) { + const evidence = asArray(evidenceItems) + .map(asObject) + .map((evidence) => ({ + id: evidence.id, + status: normalize(evidence.status), + quality: parseNumber(evidence.quality) ?? 0, + type: normalize(evidence.type) + })); + const ready = evidence.filter((item) => item.status === "ready"); + const ranked = (ready.length > 0 ? ready : evidence).sort((left, right) => right.quality - left.quality); + + return ranked[0] ?? null; +} + +function checkCompatibility(experiment, context, findings) { + const { instruments, methods, datasets, generatedAt } = context; + const instrument = instruments.get(experiment.instrumentId); + const method = methods.get(experiment.methodId); + const dataset = datasets.get(experiment.datasetId); + const startCount = findings.length; + + if (!instrument) { + addNodeMissing(findings, experiment, "instrument", experiment.instrumentId); + } + + if (!method) { + addNodeMissing(findings, experiment, "method", experiment.methodId); + } + + if (!dataset) { + addNodeMissing(findings, experiment, "dataset", experiment.datasetId); + } + + if (!instrument || !method || !dataset) { + return null; + } + + if (normalize(method.status) === "deprecated") { + addFinding( + findings, + experiment, + "high", + "method_deprecated", + `${method.name ?? method.id} is deprecated for new graph recommendations.`, + "Pick a supported method or add curator approval for legacy use.", + { methodId: method.id } + ); + } + + const supportedMethods = normalizedSet(instrument.supportedMethodIds); + if (supportedMethods.size > 0 && !supportedMethods.has(normalize(method.id))) { + addFinding( + findings, + experiment, + "medium", + "instrument_method_not_listed", + `${instrument.name ?? instrument.id} does not list ${method.name ?? method.id} as a supported method.`, + "Confirm the instrument-method edge or choose a supported instrument.", + { instrumentId: instrument.id, methodId: method.id } + ); + } + + const instrumentModalities = normalizedSet(instrument.modalities); + const methodModalities = normalizedSet(method.requiredModalities); + const datasetModality = normalize(dataset.modality); + if (methodModalities.size > 0 && (!datasetModality || !methodModalities.has(datasetModality))) { + addFinding( + findings, + experiment, + "high", + "dataset_method_modality_mismatch", + datasetModality + ? `${dataset.name ?? dataset.id} uses ${dataset.modality}, which does not match the method requirements.` + : `${dataset.name ?? dataset.id} is missing a modality required by ${method.name ?? method.id}.`, + "Use a compatible dataset or change the method assignment.", + { datasetId: dataset.id, methodId: method.id, datasetModality: datasetModality || null } + ); + } + + if (instrumentModalities.size > 0 && datasetModality && !instrumentModalities.has(datasetModality)) { + addFinding( + findings, + experiment, + "high", + "instrument_dataset_modality_mismatch", + `${instrument.name ?? instrument.id} does not support ${dataset.modality} data.`, + "Route the dataset to an instrument that supports this modality.", + { instrumentId: instrument.id, datasetId: dataset.id } + ); + } + + const acceptedOutputs = normalizedSet(method.acceptedOutputs); + const datasetFormat = normalize(dataset.format); + if (acceptedOutputs.size > 0 && (!datasetFormat || !acceptedOutputs.has(datasetFormat))) { + addFinding( + findings, + experiment, + "medium", + "dataset_format_not_accepted", + datasetFormat + ? `${dataset.format} is not listed as an accepted output for ${method.name ?? method.id}.` + : `${dataset.name ?? dataset.id} is missing an output format required by ${method.name ?? method.id}.`, + "Add a conversion step or pick a method that accepts the dataset format.", + { datasetId: dataset.id, methodId: method.id, datasetFormat: datasetFormat || null } + ); + } + + const resolution = parseNumber(dataset.resolutionMicrons); + const minResolution = parseNumber(method.maxResolutionMicrons); + if (resolution !== null && minResolution !== null && resolution > minResolution) { + addFinding( + findings, + experiment, + "medium", + "resolution_too_coarse", + `Dataset resolution ${resolution}um is coarser than the method limit ${minResolution}um.`, + "Attach a higher-resolution dataset or document the method exception.", + { datasetId: dataset.id, methodId: method.id } + ); + } + + const maxCalibrationAgeDays = parseNumber(method.maxCalibrationAgeDays); + const ageDays = calibrationAgeDays(instrument, generatedAt); + if (maxCalibrationAgeDays !== null && (ageDays === null || ageDays > maxCalibrationAgeDays)) { + addFinding( + findings, + experiment, + "medium", + "calibration_out_of_window", + `${instrument.name ?? instrument.id} calibration is missing or older than ${maxCalibrationAgeDays} days.`, + "Attach fresh calibration evidence before using this edge in recommendations.", + { instrumentId: instrument.id, ageDays } + ); + } + + const evidence = bestEvidence(experiment.evidence); + if (!evidence || evidence.status !== "ready" || evidence.quality < 0.7) { + addFinding( + findings, + experiment, + "medium", + "compatibility_evidence_weak", + "The compatibility edge has weak or non-ready evidence.", + "Add validation evidence before surfacing the relationship to researchers.", + { evidenceId: evidence?.id } + ); + } + + const added = findings.slice(startCount); + const status = added.some((finding) => finding.severity === "high") + ? "blocked" + : added.some((finding) => finding.severity === "medium") + ? "review" + : "ready"; + + return { + id: `${instrument.id}:${method.id}:${dataset.id}`, + type: "instrument_method_dataset", + status, + from: instrument.id, + through: method.id, + to: dataset.id, + evidenceQuality: evidence?.quality ?? 0, + findingCodes: added.map((finding) => finding.code) + }; +} + +function buildEntityPages(instruments, methods, datasets, edges) { + const pages = []; + + for (const instrument of instruments.values()) { + const instrumentEdges = edges.filter((edge) => edge.from === instrument.id); + pages.push({ + id: instrument.id, + type: "instrument", + title: instrument.name ?? instrument.id, + methods: [...new Set(instrumentEdges.map((edge) => edge.through))], + datasets: [...new Set(instrumentEdges.map((edge) => edge.to))], + readyEdges: instrumentEdges.filter((edge) => edge.status === "ready").length, + reviewEdges: instrumentEdges.filter((edge) => edge.status !== "ready").length + }); + } + + for (const method of methods.values()) { + const methodEdges = edges.filter((edge) => edge.through === method.id); + pages.push({ + id: method.id, + type: "method", + title: method.name ?? method.id, + instruments: [...new Set(methodEdges.map((edge) => edge.from))], + datasets: [...new Set(methodEdges.map((edge) => edge.to))] + }); + } + + for (const dataset of datasets.values()) { + const datasetEdges = edges.filter((edge) => edge.to === dataset.id); + pages.push({ + id: dataset.id, + type: "dataset", + title: dataset.name ?? dataset.id, + modality: dataset.modality, + linkedMethods: [...new Set(datasetEdges.map((edge) => edge.through))] + }); + } + + return pages; +} + +function buildRecommendations(instruments, methods, datasets, edges) { + const recommendations = []; + const readyTriples = new Set(edges.filter((edge) => edge.status === "ready").map((edge) => edge.id)); + + for (const instrument of instruments.values()) { + const supportedMethods = normalizedSet(instrument.supportedMethodIds); + const instrumentModalities = normalizedSet(instrument.modalities); + + for (const method of methods.values()) { + if (normalize(method.status) === "deprecated") { + continue; + } + + if (supportedMethods.size > 0 && !supportedMethods.has(normalize(method.id))) { + continue; + } + + const methodModalities = normalizedSet(method.requiredModalities); + if (methodModalities.size > 0 && instrumentModalities.size > 0 && !intersects(methodModalities, instrumentModalities)) { + continue; + } + + const candidateDatasets = [...datasets.values()].filter((dataset) => { + const modality = normalize(dataset.modality); + const format = normalize(dataset.format); + const acceptedOutputs = normalizedSet(method.acceptedOutputs); + const edgeId = `${instrument.id}:${method.id}:${dataset.id}`; + const matchesInstrumentModality = + instrumentModalities.size === 0 || Boolean(modality && instrumentModalities.has(modality)); + const matchesMethodModality = + methodModalities.size === 0 || Boolean(modality && methodModalities.has(modality)); + const matchesFormat = acceptedOutputs.size === 0 || Boolean(format && acceptedOutputs.has(format)); + return !readyTriples.has(edgeId) && matchesInstrumentModality && matchesMethodModality && matchesFormat; + }); + + if (candidateDatasets.length > 0) { + recommendations.push({ + type: "candidate_edge", + instrumentId: instrument.id, + methodId: method.id, + datasetIds: candidateDatasets.map((dataset) => dataset.id), + reason: "Instrument, method, and dataset metadata share compatible modality/output requirements." + }); + } + } + } + + return recommendations.slice(0, 8); +} + +function statusFromFindings(score, findings) { + if (findings.some((finding) => finding.severity === "high")) { + return "blocked"; + } + + if (findings.some((finding) => finding.severity === "medium")) { + return "review"; + } + + if (score >= 80) { + return "ready"; + } + + if (score >= 55) { + return "review"; + } + + return "blocked"; +} + +function severityCounts(findings) { + return findings.reduce( + (counts, finding) => { + counts[finding.severity] += 1; + return counts; + }, + { high: 0, medium: 0, low: 0 } + ); +} + +function curatorActions(findings) { + return [...findings] + .sort( + (left, right) => + SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity] || left.code.localeCompare(right.code) + ) + .filter((finding) => finding.severity !== "low") + .map((finding) => ({ + severity: finding.severity, + code: finding.code, + experimentId: finding.experimentId, + nodeId: finding.nodeId, + action: finding.action + })); +} + +export function evaluateInstrumentMethodCompatibility(input) { + const packet = asObject(input); + const project = asObject(packet.project); + const instruments = indexById(packet.instruments); + const methods = indexById(packet.methods); + const datasets = indexById(packet.datasets); + const experiments = asArray(packet.experiments).map(asObject); + const findings = []; + + if (!packet.generatedAt) { + throw new Error("generatedAt is required"); + } + + if (experiments.length === 0) { + addFinding( + findings, + { id: "packet" }, + "high", + "experiment_set_empty", + "No experiment edges were supplied for graph compatibility review.", + "Add experiment edges that connect instruments, methods, and datasets." + ); + } + + const context = { instruments, methods, datasets, generatedAt: packet.generatedAt }; + const compatibilityEdges = experiments + .map((experiment) => checkCompatibility(experiment, context, findings)) + .filter(Boolean); + const entityPages = buildEntityPages(instruments, methods, datasets, compatibilityEdges); + const recommendations = buildRecommendations(instruments, methods, datasets, compatibilityEdges); + const penalty = findings.reduce((total, finding) => total + SEVERITY_WEIGHT[finding.severity], 0); + const score = Math.max(0, 100 - penalty); + const counts = severityCounts(findings); + const manifestDigest = digest({ + project, + instruments: [...instruments.values()], + methods: [...methods.values()], + datasets: [...datasets.values()], + experiments, + generatedAt: packet.generatedAt + }); + + return { + projectId: project.id ?? "unlabeled-project", + title: project.title ?? "Untitled project", + status: statusFromFindings(score, findings), + score, + counts, + findings, + compatibilityEdges, + entityPages, + recommendations, + graphQueries: [ + "instrument -> compatible methods -> datasets", + "dataset -> candidate instruments", + "method -> blocked experiments" + ], + curatorActions: curatorActions(findings), + auditEvents: [ + { + type: "instrument_method_compatibility_evaluated", + at: packet.generatedAt, + edges: compatibilityEdges.length, + findings: findings.length, + findingsDigest: digest(findings) + } + ], + manifestDigest + }; +} + +export function renderInstrumentMethodCompatibilityReport(result) { + const readyEdges = result.compatibilityEdges.filter((edge) => edge.status === "ready").length; + const reviewEdges = result.compatibilityEdges.length - readyEdges; + const lines = [ + "Instrument Method Compatibility", + `${result.title}: ${result.status} (${result.score}/100)`, + `Edges ready/review: ${readyEdges}/${reviewEdges}`, + `Findings high/medium/low: ${result.counts.high}/${result.counts.medium}/${result.counts.low}`, + `Manifest: ${result.manifestDigest}`, + "", + "Curator actions:" + ]; + + if (result.curatorActions.length === 0) { + lines.push("- none"); + } else { + for (const action of result.curatorActions) { + lines.push(`- ${action.severity} ${action.code}: ${action.action}`); + } + } + + return lines.join("\n"); +} diff --git a/instrument-method-compatibility-graph/test/instrument-method-compatibility-graph.test.js b/instrument-method-compatibility-graph/test/instrument-method-compatibility-graph.test.js new file mode 100644 index 0000000..a56f490 --- /dev/null +++ b/instrument-method-compatibility-graph/test/instrument-method-compatibility-graph.test.js @@ -0,0 +1,372 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + evaluateInstrumentMethodCompatibility, + renderInstrumentMethodCompatibilityReport +} from "../src/instrument-method-compatibility-graph.js"; + +function basePacket(overrides = {}) { + return { + generatedAt: "2026-05-16T17:10:00Z", + project: { + id: "project-1", + title: "Clean Graph" + }, + instruments: [ + { + id: "inst-a", + name: "Instrument A", + modalities: ["imaging"], + supportedMethodIds: ["method-a"], + calibrationDate: "2026-05-01T00:00:00Z" + } + ], + methods: [ + { + id: "method-a", + name: "Method A", + status: "active", + requiredModalities: ["imaging"], + acceptedOutputs: ["ome-tiff"], + maxResolutionMicrons: 1, + maxCalibrationAgeDays: 60 + } + ], + datasets: [ + { + id: "data-a", + name: "Dataset A", + modality: "imaging", + format: "ome-tiff", + resolutionMicrons: 0.5 + } + ], + experiments: [ + { + id: "exp-a", + instrumentId: "inst-a", + methodId: "method-a", + datasetId: "data-a", + evidence: [{ id: "ev-a", type: "validation", status: "ready", quality: 0.9 }] + } + ], + ...overrides + }; +} + +test("allows a clean compatible graph edge", () => { + const result = evaluateInstrumentMethodCompatibility(basePacket()); + + assert.equal(result.status, "ready"); + assert.equal(result.score, 100); + assert.equal(result.compatibilityEdges[0].status, "ready"); + assert.equal(result.curatorActions.length, 0); +}); + +test("requires generatedAt", () => { + assert.throws(() => evaluateInstrumentMethodCompatibility({}), /generatedAt is required/); +}); + +test("blocks missing graph nodes", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + experiments: [ + { + id: "exp-missing", + instrumentId: "inst-missing", + methodId: "method-a", + datasetId: "data-a", + evidence: [] + } + ] + }) + ); + + assert.equal(result.status, "blocked"); + assert(result.findings.some((finding) => finding.code === "instrument_node_missing")); +}); + +test("ignores malformed graph nodes instead of throwing", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + instruments: [null, "bad-node", ...basePacket().instruments] + }) + ); + + assert.equal(result.compatibilityEdges.length, 1); + assert.equal(result.status, "ready"); +}); + +test("blocks dataset and method modality mismatches", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + datasets: [{ ...basePacket().datasets[0], modality: "single-cell" }] + }) + ); + + assert(result.findings.some((finding) => finding.code === "dataset_method_modality_mismatch")); + assert(result.findings.some((finding) => finding.code === "instrument_dataset_modality_mismatch")); +}); + +test("flags missing dataset modality with a readable finding", () => { + const dataset = { ...basePacket().datasets[0] }; + delete dataset.modality; + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + datasets: [dataset] + }) + ); + const finding = result.findings.find((finding) => finding.code === "dataset_method_modality_mismatch"); + + assert(finding); + assert.match(finding.message, /missing a modality/); + assert(!finding.message.includes("undefined")); +}); + +test("flags deprecated methods", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + methods: [{ ...basePacket().methods[0], status: "deprecated" }] + }) + ); + + assert.equal(result.status, "blocked"); + assert(result.findings.some((finding) => finding.code === "method_deprecated")); +}); + +test("flags instruments that do not list a method", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + instruments: [{ ...basePacket().instruments[0], supportedMethodIds: ["other-method"] }] + }) + ); + + assert(result.findings.some((finding) => finding.code === "instrument_method_not_listed")); +}); + +test("sets overall status to review for medium findings", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + instruments: [{ ...basePacket().instruments[0], supportedMethodIds: ["other-method"] }] + }) + ); + + assert.equal(result.status, "review"); + assert.equal(result.compatibilityEdges[0].status, "review"); +}); + +test("flags dataset formats that are not accepted by the method", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + datasets: [{ ...basePacket().datasets[0], format: "csv" }] + }) + ); + + assert(result.findings.some((finding) => finding.code === "dataset_format_not_accepted")); +}); + +test("flags missing dataset formats when the method requires accepted outputs", () => { + const dataset = { ...basePacket().datasets[0] }; + delete dataset.format; + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + datasets: [dataset] + }) + ); + + assert(result.findings.some((finding) => finding.code === "dataset_format_not_accepted")); + assert.equal(result.compatibilityEdges[0].status, "review"); +}); + +test("flags coarse resolution", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + datasets: [{ ...basePacket().datasets[0], resolutionMicrons: 4 }] + }) + ); + + assert(result.findings.some((finding) => finding.code === "resolution_too_coarse")); +}); + +test("flags stale or missing calibration", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + instruments: [{ ...basePacket().instruments[0], calibrationDate: "2025-01-01T00:00:00Z" }] + }) + ); + + assert(result.findings.some((finding) => finding.code === "calibration_out_of_window")); +}); + +test("flags weak compatibility evidence", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + experiments: [ + { + ...basePacket().experiments[0], + evidence: [{ id: "ev-draft", type: "notebook", status: "draft", quality: 0.5 }] + } + ] + }) + ); + + assert(result.findings.some((finding) => finding.code === "compatibility_evidence_weak")); +}); + +test("ignores malformed evidence entries instead of throwing", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + experiments: [ + { + ...basePacket().experiments[0], + evidence: [null, undefined, "bad-entry", { id: "ev-ready", status: "ready", quality: 0.8 }] + } + ] + }) + ); + + assert.equal(result.status, "ready"); + assert.equal(result.compatibilityEdges[0].evidenceQuality, 0.8); +}); + +test("uses ready evidence over higher-quality draft evidence", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + experiments: [ + { + ...basePacket().experiments[0], + evidence: [ + { id: "ev-draft", type: "notebook", status: "draft", quality: 0.98 }, + { id: "ev-ready", type: "validation", status: "ready", quality: 0.82 } + ] + } + ] + }) + ); + + assert.equal(result.compatibilityEdges[0].evidenceQuality, 0.82); + assert(!result.findings.some((finding) => finding.code === "compatibility_evidence_weak")); +}); + +test("builds entity pages and recommendations", () => { + const result = evaluateInstrumentMethodCompatibility(basePacket({ experiments: [] })); + + assert(result.entityPages.some((page) => page.type === "instrument")); + assert(result.recommendations.some((recommendation) => recommendation.type === "candidate_edge")); +}); + +test("recommends extra compatible datasets for an existing instrument-method pair", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + datasets: [ + ...basePacket().datasets, + { + id: "data-b", + name: "Dataset B", + modality: "imaging", + format: "ome-tiff", + resolutionMicrons: 0.7 + } + ] + }) + ); + + assert( + result.recommendations.some( + (recommendation) => recommendation.instrumentId === "inst-a" && recommendation.datasetIds.includes("data-b") + ) + ); + assert( + !result.recommendations.some( + (recommendation) => recommendation.instrumentId === "inst-a" && recommendation.datasetIds.includes("data-a") + ) + ); +}); + +test("does not recommend unsupported dataset modalities", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + instruments: [{ ...basePacket().instruments[0], supportedMethodIds: ["method-a", "method-b"] }], + methods: [ + ...basePacket().methods, + { + id: "method-b", + name: "Method B", + status: "active", + requiredModalities: ["single-cell"], + acceptedOutputs: ["fcs"] + } + ], + datasets: [ + ...basePacket().datasets, + { + id: "data-flow", + name: "Flow Dataset", + modality: "single-cell", + format: "fcs" + } + ], + experiments: [] + }) + ); + + assert( + !result.recommendations.some( + (recommendation) => recommendation.methodId === "method-b" && recommendation.datasetIds.includes("data-flow") + ) + ); +}); + +test("does not recommend datasets with missing required modality or format", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + experiments: [], + datasets: [ + { + id: "data-no-modality", + name: "No Modality", + format: "ome-tiff" + }, + { + id: "data-no-format", + name: "No Format", + modality: "imaging" + } + ] + }) + ); + + assert.equal(result.recommendations.length, 0); +}); + +test("does not recommend deprecated methods", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + experiments: [], + methods: [{ ...basePacket().methods[0], status: "deprecated" }] + }) + ); + + assert.equal(result.recommendations.length, 0); +}); + +test("produces deterministic digests", () => { + const first = evaluateInstrumentMethodCompatibility(basePacket()); + const second = evaluateInstrumentMethodCompatibility(basePacket()); + + assert.equal(first.manifestDigest, second.manifestDigest); + assert.equal(first.auditEvents[0].findingsDigest, second.auditEvents[0].findingsDigest); +}); + +test("renders a curator-friendly report", () => { + const result = evaluateInstrumentMethodCompatibility( + basePacket({ + methods: [{ ...basePacket().methods[0], status: "deprecated" }] + }) + ); + const report = renderInstrumentMethodCompatibilityReport(result); + + assert.match(report, /Instrument Method Compatibility/); + assert.match(report, /method_deprecated/); + assert.match(report, /Manifest:/); +});