diff --git a/knowledge-graph-software-version-compatibility-guard/README.md b/knowledge-graph-software-version-compatibility-guard/README.md
new file mode 100644
index 00000000..870e90cb
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/README.md
@@ -0,0 +1,30 @@
+# Knowledge Graph Software Version Compatibility Guard
+
+Self-contained reviewer module for issue #17, Scientific Knowledge Graph Integration.
+
+The guard evaluates candidate knowledge-graph recommendation paths before they appear in discovery mode or entity-page packets. It checks whether software packages, runtimes, container images, workflow engines, lockfiles, and runtime captures are compatible enough for a researcher to trust and rerun the graph path.
+
+## What It Does
+
+- Blocks unsafe graph recommendations when runtime, package, container, or workflow versions cannot satisfy declared compatibility ranges.
+- Keeps compatible paths visible while routing weak provenance, such as missing lockfiles or stale runtime captures, to curator review.
+- Produces an entity-page packet with visible paths, suppressed paths, findings, and required curator actions.
+- Generates deterministic JSON, Markdown, SVG, and MP4 review artifacts from synthetic fixtures only.
+
+## Files
+
+- `index.js` - dependency-free evaluator and Markdown packet builder.
+- `sample-data.js` - synthetic graph scenarios for blocked, review, and visible recommendations.
+- `test.js` - Node test coverage for suppression, curator review, and compatible paths.
+- `demo.js` - report generator and optional MP4 artifact writer.
+- `requirements-map.md` - issue requirement mapping.
+- `reports/` - generated reviewer artifacts.
+
+## Run
+
+```bash
+node --test knowledge-graph-software-version-compatibility-guard/test.js
+FFMPEG_PATH=/path/to/ffmpeg node knowledge-graph-software-version-compatibility-guard/demo.js
+```
+
+No package registry, container registry, dataset, credential, external API, or network calls are used.
diff --git a/knowledge-graph-software-version-compatibility-guard/demo.js b/knowledge-graph-software-version-compatibility-guard/demo.js
new file mode 100644
index 00000000..9fd0aff5
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/demo.js
@@ -0,0 +1,148 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const {spawnSync} = require('node:child_process');
+
+const {
+ evaluateSoftwareVersionCompatibility,
+ buildSoftwareCompatibilityReviewPacket,
+} = require('./index');
+const {scenarios} = require('./sample-data');
+
+const reportsDir = path.join(__dirname, 'reports');
+const framesDir = path.join(reportsDir, 'frames');
+fs.mkdirSync(reportsDir, {recursive: true});
+
+const evaluations = scenarios.map((scenario) => ({
+ scenario: scenario.name,
+ ...evaluateSoftwareVersionCompatibility(scenario),
+}));
+
+const decisionCounts = evaluations.reduce((counts, item) => {
+ counts[item.decision] = (counts[item.decision] || 0) + 1;
+ return counts;
+}, {});
+const totals = evaluations.reduce(
+ (sum, item) => {
+ sum.paths += item.summary.totalPathCount;
+ sum.suppressed += item.summary.suppressedPathCount;
+ sum.review += item.summary.reviewPathCount;
+ sum.findings += item.summary.findingCount;
+ sum.actions += item.summary.actionCount;
+ return sum;
+ },
+ {paths: 0, suppressed: 0, review: 0, findings: 0, actions: 0}
+);
+
+const packetJson = JSON.stringify(evaluations, null, 2);
+const reviewerPacket = evaluations.map(buildSoftwareCompatibilityReviewPacket).join('\n---\n');
+const svg = `
+`;
+
+fs.writeFileSync(path.join(reportsDir, 'compatibility-review.json'), `${packetJson}\n`);
+fs.writeFileSync(path.join(reportsDir, 'software-compatibility-review.md'), reviewerPacket);
+fs.writeFileSync(path.join(reportsDir, 'summary.svg'), svg);
+
+function fillRect(buffer, width, x0, y0, rectWidth, rectHeight, color) {
+ const [r, g, b] = color;
+ const height = Math.floor(buffer.length / (width * 3));
+ const x1 = Math.min(width, x0 + rectWidth);
+ const y1 = Math.min(height, y0 + rectHeight);
+ for (let y = Math.max(0, y0); y < y1; y += 1) {
+ for (let x = Math.max(0, x0); x < x1; x += 1) {
+ const offset = (y * width + x) * 3;
+ buffer[offset] = r;
+ buffer[offset + 1] = g;
+ buffer[offset + 2] = b;
+ }
+ }
+}
+
+function writePpmFrame(filePath, frameIndex, frameCount) {
+ const width = 640;
+ const height = 360;
+ const buffer = Buffer.alloc(width * height * 3);
+ for (let i = 0; i < width * height; i += 1) {
+ buffer[i * 3] = 23;
+ buffer[i * 3 + 1] = 32;
+ buffer[i * 3 + 2] = 42;
+ }
+
+ const progress = frameIndex / Math.max(1, frameCount - 1);
+ fillRect(buffer, width, 40, 40, 560, 40, [248, 250, 252]);
+ fillRect(buffer, width, 40, 106, 150, 122, [127, 29, 29]);
+ fillRect(buffer, width, 245, 106, 150, 122, [133, 77, 14]);
+ fillRect(buffer, width, 450, 106, 150, 122, [22, 101, 52]);
+ fillRect(buffer, width, 78, 257, 112, 32, [248, 113, 113]);
+ fillRect(buffer, width, 264, 257, 112, 32, [251, 191, 36]);
+ fillRect(buffer, width, 450, 257, 112, 32, [74, 222, 128]);
+ fillRect(buffer, width, 40, 318, Math.round(560 * progress), 18, [167, 243, 208]);
+ fillRect(buffer, width, 40 + Math.round(512 * progress), 309, 48, 36, [241, 245, 249]);
+
+ const header = Buffer.from(`P6\n${width} ${height}\n255\n`);
+ fs.writeFileSync(filePath, Buffer.concat([header, buffer]));
+}
+
+function createDemoVideo() {
+ const ffmpegPath = process.env.FFMPEG_PATH;
+ if (!ffmpegPath) {
+ console.log('FFMPEG_PATH not set; skipped MP4 generation.');
+ return;
+ }
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ fs.mkdirSync(framesDir, {recursive: true});
+ const frameCount = 72;
+ for (let index = 0; index < frameCount; index += 1) {
+ writePpmFrame(path.join(framesDir, `frame-${String(index).padStart(3, '0')}.ppm`), index, frameCount);
+ }
+
+ const output = path.join(reportsDir, 'demo.mp4');
+ const result = spawnSync(ffmpegPath, [
+ '-y',
+ '-framerate',
+ '24',
+ '-i',
+ path.join(framesDir, 'frame-%03d.ppm'),
+ '-c:v',
+ 'libx264',
+ '-pix_fmt',
+ 'yuv420p',
+ output,
+ ], {encoding: 'utf8'});
+
+ fs.rmSync(framesDir, {recursive: true, force: true});
+ if (result.status !== 0) {
+ throw new Error(result.stderr || 'ffmpeg failed');
+ }
+}
+
+createDemoVideo();
+
+console.log(JSON.stringify({
+ scenarios: evaluations.length,
+ reportsDir,
+ decisions: decisionCounts,
+ totals,
+}, null, 2));
diff --git a/knowledge-graph-software-version-compatibility-guard/index.js b/knowledge-graph-software-version-compatibility-guard/index.js
new file mode 100644
index 00000000..468af9c8
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/index.js
@@ -0,0 +1,331 @@
+function list(value) {
+ return Array.isArray(value) ? value : [];
+}
+
+function clean(value) {
+ return String(value || '').trim();
+}
+
+function normalize(value) {
+ return clean(value).toLowerCase();
+}
+
+function parseVersion(value) {
+ const match = clean(value).match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/i);
+ if (!match) {
+ return null;
+ }
+ return [Number(match[1] || 0), Number(match[2] || 0), Number(match[3] || 0)];
+}
+
+function compareVersions(left, right) {
+ const a = parseVersion(left);
+ const b = parseVersion(right);
+ if (!a || !b) {
+ return 0;
+ }
+ for (let index = 0; index < 3; index += 1) {
+ if (a[index] > b[index]) return 1;
+ if (a[index] < b[index]) return -1;
+ }
+ return 0;
+}
+
+function satisfiesRange(installed, required) {
+ const installedVersion = parseVersion(installed);
+ if (!installedVersion || !clean(required)) {
+ return true;
+ }
+
+ const clauses = clean(required).split(/\s+/).filter(Boolean);
+ return clauses.every((clause) => {
+ const match = clause.match(/^(>=|<=|>|<|=|~\^?|\^|~)?\s*v?([0-9]+(?:\.[0-9]+){0,2})$/);
+ if (!match) {
+ return true;
+ }
+ const operator = match[1] || '=';
+ const target = match[2];
+ const comparison = compareVersions(installed, target);
+ if (operator === '>=') return comparison >= 0;
+ if (operator === '<=') return comparison <= 0;
+ if (operator === '>') return comparison > 0;
+ if (operator === '<') return comparison < 0;
+ if (operator === '^') {
+ const targetParts = parseVersion(target);
+ const installedParts = parseVersion(installed);
+ return comparison >= 0 && installedParts[0] === targetParts[0];
+ }
+ if (operator === '~' || operator === '~^') {
+ const targetParts = parseVersion(target);
+ const installedParts = parseVersion(installed);
+ return comparison >= 0 && installedParts[0] === targetParts[0] && installedParts[1] === targetParts[1];
+ }
+ return comparison === 0;
+ });
+}
+
+function isRuntimePackage(name) {
+ return ['python', 'node', 'node.js', 'r', 'julia', 'java', 'ruby'].includes(normalize(name));
+}
+
+function ageDays(older, newer) {
+ const olderTime = Date.parse(older);
+ const newerTime = Date.parse(newer);
+ if (!Number.isFinite(olderTime) || !Number.isFinite(newerTime)) {
+ return 0;
+ }
+ return Math.floor((newerTime - olderTime) / 86400000);
+}
+
+function finding(type, path, subject, message, severity = 'review') {
+ return {
+ type,
+ severity,
+ pathId: path.id || 'unknown-path',
+ subject,
+ message,
+ };
+}
+
+function action(type, target, reason) {
+ return {type, target, reason};
+}
+
+function titleForPath(path) {
+ const titles = list(path.nodes).map((node) => node.title || node.id).filter(Boolean);
+ return titles.length ? titles.join(' -> ') : path.id || 'unknown path';
+}
+
+function evaluatePath(path, generatedAt) {
+ const findings = [];
+ const curatorActions = [];
+
+ for (const item of list(path.software)) {
+ if (!satisfiesRange(item.installed, item.required)) {
+ const runtime = isRuntimePackage(item.name);
+ const type = runtime ? 'runtime-version-incompatible' : 'package-version-incompatible';
+ findings.push(
+ finding(
+ type,
+ path,
+ item.name,
+ `${item.name} ${item.installed || 'unversioned'} does not satisfy ${item.required || 'the required range'} for ${titleForPath(path)}.`,
+ 'block'
+ )
+ );
+ curatorActions.push(
+ action(
+ runtime ? 'pin-compatible-runtime' : 'update-package-lock',
+ item.name || 'unknown software',
+ `Install ${item.name || 'software'} ${item.required || 'at a compatible version'} before this graph path is recommended.`
+ )
+ );
+ }
+ }
+
+ for (const container of list(path.containers)) {
+ if (!clean(container.digest)) {
+ findings.push(
+ finding(
+ 'container-digest-missing',
+ path,
+ container.image || container.tag || 'container',
+ `Container ${container.image || 'image'}:${container.tag || 'tag'} is not pinned by digest.`,
+ 'block'
+ )
+ );
+ curatorActions.push(
+ action(
+ 'pin-container-digest',
+ container.image || 'container image',
+ 'Record an immutable sha256 digest before exposing this path in discovery recommendations.'
+ )
+ );
+ }
+ }
+
+ for (const workflow of list(path.workflows)) {
+ if (!satisfiesRange(workflow.installed, workflow.required)) {
+ findings.push(
+ finding(
+ 'workflow-engine-incompatible',
+ path,
+ workflow.engine || 'workflow engine',
+ `${workflow.engine || 'Workflow engine'} ${workflow.installed || 'unversioned'} does not satisfy ${workflow.required || 'the required range'}.`,
+ 'block'
+ )
+ );
+ curatorActions.push(
+ action(
+ 'upgrade-workflow-engine',
+ workflow.engine || 'workflow engine',
+ `Pin ${workflow.engine || 'workflow engine'} ${workflow.required || 'at a compatible version'} for rerunnable graph provenance.`
+ )
+ );
+ }
+ }
+
+ const lockfileEvidence = list(path.evidence).find((item) => normalize(item.type) === 'lockfile');
+ if (lockfileEvidence && lockfileEvidence.present === false) {
+ findings.push(
+ finding(
+ 'missing-lockfile-provenance',
+ path,
+ lockfileEvidence.id || 'lockfile',
+ 'Software versions are compatible but no lockfile evidence is present for curator audit.'
+ )
+ );
+ curatorActions.push(
+ action(
+ 'attach-lockfile',
+ lockfileEvidence.id || path.id || 'path',
+ 'Attach a package lockfile before this recommendation is treated as fully reproducible.'
+ )
+ );
+ }
+
+ const runtimeCapture = list(path.evidence).find((item) => normalize(item.type) === 'runtime-capture');
+ if (runtimeCapture && ageDays(runtimeCapture.capturedAt, generatedAt) > 180) {
+ findings.push(
+ finding(
+ 'stale-runtime-capture',
+ path,
+ runtimeCapture.id || 'runtime capture',
+ `Runtime capture is ${ageDays(runtimeCapture.capturedAt, generatedAt)} days old and needs refresh before confidence can be upgraded.`
+ )
+ );
+ curatorActions.push(
+ action(
+ 'refresh-runtime-capture',
+ runtimeCapture.id || path.id || 'runtime capture',
+ 'Run a fresh environment capture so graph recommendations reflect current software compatibility.'
+ )
+ );
+ }
+
+ const hasBlocker = findings.some((item) => item.severity === 'block');
+ return {
+ ...path,
+ title: titleForPath(path),
+ findings,
+ curatorActions,
+ compatibilityScore: Math.max(0, 100 - findings.filter((item) => item.severity === 'block').length * 25 - findings.filter((item) => item.severity !== 'block').length * 10),
+ status: hasBlocker ? 'suppressed' : findings.length ? 'needs-review' : 'visible',
+ };
+}
+
+function summarizePaths(paths) {
+ const suppressedPathCount = paths.filter((path) => path.status === 'suppressed').length;
+ const reviewPathCount = paths.filter((path) => path.status === 'needs-review').length;
+ const visiblePathCount = paths.filter((path) => path.status !== 'suppressed').length;
+ return {
+ totalPathCount: paths.length,
+ suppressedPathCount,
+ reviewPathCount,
+ visiblePathCount,
+ findingCount: paths.reduce((sum, path) => sum + path.findings.length, 0),
+ actionCount: paths.reduce((sum, path) => sum + path.curatorActions.length, 0),
+ };
+}
+
+function evaluateSoftwareVersionCompatibility(input = {}) {
+ const generatedAt = input.generatedAt || new Date(0).toISOString();
+ const evaluatedPaths = list(input.paths).map((path) => evaluatePath(path, generatedAt));
+ const findings = evaluatedPaths.flatMap((path) => path.findings);
+ const curatorActions = evaluatedPaths.flatMap((path) => path.curatorActions);
+ const suppressedPaths = evaluatedPaths
+ .filter((path) => path.status === 'suppressed')
+ .map((path) => ({
+ id: path.id,
+ from: path.from,
+ to: path.to,
+ reason: path.findings
+ .filter((item) => item.severity === 'block')
+ .map((item) => item.message)
+ .join(' '),
+ findingTypes: path.findings.map((item) => item.type),
+ }));
+ const visiblePaths = evaluatedPaths
+ .filter((path) => path.status !== 'suppressed')
+ .map((path) => ({
+ id: path.id,
+ from: path.from,
+ to: path.to,
+ title: path.title,
+ status: path.status,
+ compatibilityScore: path.compatibilityScore,
+ findingTypes: path.findings.map((item) => item.type),
+ nodes: path.nodes,
+ }));
+ const summary = summarizePaths(evaluatedPaths);
+ const decision = summary.suppressedPathCount > 0
+ ? 'suppress-recommendation'
+ : summary.reviewPathCount > 0
+ ? 'curator-review'
+ : 'show-recommendation';
+
+ return {
+ generatedAt,
+ recommendationId: input.recommendationId || 'unknown-recommendation',
+ decision,
+ summary,
+ findings,
+ curatorActions,
+ suppressedPaths,
+ visiblePaths,
+ entityPagePacket: {
+ recommendationId: input.recommendationId || 'unknown-recommendation',
+ decision,
+ generatedAt,
+ visiblePaths,
+ suppressedPaths,
+ curatorActions,
+ },
+ };
+}
+
+function buildSoftwareCompatibilityReviewPacket(result) {
+ const lines = [
+ `# Software Compatibility Review: ${result.recommendationId}`,
+ '',
+ `Decision: ${result.decision}`,
+ `Generated: ${result.generatedAt}`,
+ '',
+ '## Visible Paths',
+ ];
+
+ if (result.visiblePaths.length === 0) {
+ lines.push('- None');
+ } else {
+ for (const path of result.visiblePaths) {
+ const titles = list(path.nodes).map((node) => node.title || node.id).join(' -> ');
+ lines.push(`- ${path.id}: ${titles} (score ${path.compatibilityScore})`);
+ }
+ }
+
+ lines.push('', '## Suppressed Paths');
+ if (result.suppressedPaths.length === 0) {
+ lines.push('- None');
+ } else {
+ for (const path of result.suppressedPaths) {
+ lines.push(`- ${path.id}: ${path.reason}`);
+ }
+ }
+
+ lines.push('', '## Curator Actions');
+ if (result.curatorActions.length === 0) {
+ lines.push('- No curator action required');
+ } else {
+ for (const item of result.curatorActions) {
+ lines.push(`- ${item.type}: ${item.target} - ${item.reason}`);
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+module.exports = {
+ evaluateSoftwareVersionCompatibility,
+ buildSoftwareCompatibilityReviewPacket,
+ satisfiesRange,
+};
diff --git a/knowledge-graph-software-version-compatibility-guard/reports/compatibility-review.json b/knowledge-graph-software-version-compatibility-guard/reports/compatibility-review.json
new file mode 100644
index 00000000..d1717cd0
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/reports/compatibility-review.json
@@ -0,0 +1,343 @@
+[
+ {
+ "scenario": "RNA velocity notebook has incompatible runtime stack",
+ "generatedAt": "2026-05-22T16:00:00Z",
+ "recommendationId": "rec-rna-velocity-notebook",
+ "decision": "suppress-recommendation",
+ "summary": {
+ "totalPathCount": 1,
+ "suppressedPathCount": 1,
+ "reviewPathCount": 0,
+ "visiblePathCount": 0,
+ "findingCount": 5,
+ "actionCount": 5
+ },
+ "findings": [
+ {
+ "type": "runtime-version-incompatible",
+ "severity": "block",
+ "pathId": "path-rna-velocity",
+ "subject": "python",
+ "message": "python 3.8.18 does not satisfy >=3.10 <3.12 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3."
+ },
+ {
+ "type": "package-version-incompatible",
+ "severity": "block",
+ "pathId": "path-rna-velocity",
+ "subject": "scanpy",
+ "message": "scanpy 1.8.2 does not satisfy >=1.10 <2.0 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3."
+ },
+ {
+ "type": "package-version-incompatible",
+ "severity": "block",
+ "pathId": "path-rna-velocity",
+ "subject": "anndata",
+ "message": "anndata 0.8.0 does not satisfy >=0.10 <0.11 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3."
+ },
+ {
+ "type": "container-digest-missing",
+ "severity": "block",
+ "pathId": "path-rna-velocity",
+ "subject": "ghcr.io/scibase/velocity",
+ "message": "Container ghcr.io/scibase/velocity:latest is not pinned by digest."
+ },
+ {
+ "type": "workflow-engine-incompatible",
+ "severity": "block",
+ "pathId": "path-rna-velocity",
+ "subject": "nextflow",
+ "message": "nextflow 22.10.6 does not satisfy >=23.10 <24.0."
+ }
+ ],
+ "curatorActions": [
+ {
+ "type": "pin-compatible-runtime",
+ "target": "python",
+ "reason": "Install python >=3.10 <3.12 before this graph path is recommended."
+ },
+ {
+ "type": "update-package-lock",
+ "target": "scanpy",
+ "reason": "Install scanpy >=1.10 <2.0 before this graph path is recommended."
+ },
+ {
+ "type": "update-package-lock",
+ "target": "anndata",
+ "reason": "Install anndata >=0.10 <0.11 before this graph path is recommended."
+ },
+ {
+ "type": "pin-container-digest",
+ "target": "ghcr.io/scibase/velocity",
+ "reason": "Record an immutable sha256 digest before exposing this path in discovery recommendations."
+ },
+ {
+ "type": "upgrade-workflow-engine",
+ "target": "nextflow",
+ "reason": "Pin nextflow >=23.10 <24.0 for rerunnable graph provenance."
+ }
+ ],
+ "suppressedPaths": [
+ {
+ "id": "path-rna-velocity",
+ "from": "concept-rna-velocity",
+ "to": "notebook-velocity-2024",
+ "reason": "python 3.8.18 does not satisfy >=3.10 <3.12 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. scanpy 1.8.2 does not satisfy >=1.10 <2.0 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. anndata 0.8.0 does not satisfy >=0.10 <0.11 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. Container ghcr.io/scibase/velocity:latest is not pinned by digest. nextflow 22.10.6 does not satisfy >=23.10 <24.0.",
+ "findingTypes": [
+ "runtime-version-incompatible",
+ "package-version-incompatible",
+ "package-version-incompatible",
+ "container-digest-missing",
+ "workflow-engine-incompatible"
+ ]
+ }
+ ],
+ "visiblePaths": [],
+ "entityPagePacket": {
+ "recommendationId": "rec-rna-velocity-notebook",
+ "decision": "suppress-recommendation",
+ "generatedAt": "2026-05-22T16:00:00Z",
+ "visiblePaths": [],
+ "suppressedPaths": [
+ {
+ "id": "path-rna-velocity",
+ "from": "concept-rna-velocity",
+ "to": "notebook-velocity-2024",
+ "reason": "python 3.8.18 does not satisfy >=3.10 <3.12 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. scanpy 1.8.2 does not satisfy >=1.10 <2.0 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. anndata 0.8.0 does not satisfy >=0.10 <0.11 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. Container ghcr.io/scibase/velocity:latest is not pinned by digest. nextflow 22.10.6 does not satisfy >=23.10 <24.0.",
+ "findingTypes": [
+ "runtime-version-incompatible",
+ "package-version-incompatible",
+ "package-version-incompatible",
+ "container-digest-missing",
+ "workflow-engine-incompatible"
+ ]
+ }
+ ],
+ "curatorActions": [
+ {
+ "type": "pin-compatible-runtime",
+ "target": "python",
+ "reason": "Install python >=3.10 <3.12 before this graph path is recommended."
+ },
+ {
+ "type": "update-package-lock",
+ "target": "scanpy",
+ "reason": "Install scanpy >=1.10 <2.0 before this graph path is recommended."
+ },
+ {
+ "type": "update-package-lock",
+ "target": "anndata",
+ "reason": "Install anndata >=0.10 <0.11 before this graph path is recommended."
+ },
+ {
+ "type": "pin-container-digest",
+ "target": "ghcr.io/scibase/velocity",
+ "reason": "Record an immutable sha256 digest before exposing this path in discovery recommendations."
+ },
+ {
+ "type": "upgrade-workflow-engine",
+ "target": "nextflow",
+ "reason": "Pin nextflow >=23.10 <24.0 for rerunnable graph provenance."
+ }
+ ]
+ }
+ },
+ {
+ "scenario": "Spatial protocol is compatible but provenance needs curator review",
+ "generatedAt": "2026-05-22T16:10:00Z",
+ "recommendationId": "rec-spatial-transcriptomics-protocol",
+ "decision": "curator-review",
+ "summary": {
+ "totalPathCount": 1,
+ "suppressedPathCount": 0,
+ "reviewPathCount": 1,
+ "visiblePathCount": 1,
+ "findingCount": 2,
+ "actionCount": 2
+ },
+ "findings": [
+ {
+ "type": "missing-lockfile-provenance",
+ "severity": "review",
+ "pathId": "path-spatial-protocol",
+ "subject": "poetry.lock",
+ "message": "Software versions are compatible but no lockfile evidence is present for curator audit."
+ },
+ {
+ "type": "stale-runtime-capture",
+ "severity": "review",
+ "pathId": "path-spatial-protocol",
+ "subject": "runner-991",
+ "message": "Runtime capture is 294 days old and needs refresh before confidence can be upgraded."
+ }
+ ],
+ "curatorActions": [
+ {
+ "type": "attach-lockfile",
+ "target": "poetry.lock",
+ "reason": "Attach a package lockfile before this recommendation is treated as fully reproducible."
+ },
+ {
+ "type": "refresh-runtime-capture",
+ "target": "runner-991",
+ "reason": "Run a fresh environment capture so graph recommendations reflect current software compatibility."
+ }
+ ],
+ "suppressedPaths": [],
+ "visiblePaths": [
+ {
+ "id": "path-spatial-protocol",
+ "from": "protocol-spatial-v2",
+ "to": "dataset-spatial-lung",
+ "title": "Spatial transcriptomics v2 -> Lung atlas matrix",
+ "status": "needs-review",
+ "compatibilityScore": 80,
+ "findingTypes": [
+ "missing-lockfile-provenance",
+ "stale-runtime-capture"
+ ],
+ "nodes": [
+ {
+ "id": "protocol-spatial-v2",
+ "type": "protocol",
+ "title": "Spatial transcriptomics v2"
+ },
+ {
+ "id": "dataset-spatial-lung",
+ "type": "dataset",
+ "title": "Lung atlas matrix",
+ "format": {
+ "name": "loom",
+ "version": "3.0.0"
+ }
+ }
+ ]
+ }
+ ],
+ "entityPagePacket": {
+ "recommendationId": "rec-spatial-transcriptomics-protocol",
+ "decision": "curator-review",
+ "generatedAt": "2026-05-22T16:10:00Z",
+ "visiblePaths": [
+ {
+ "id": "path-spatial-protocol",
+ "from": "protocol-spatial-v2",
+ "to": "dataset-spatial-lung",
+ "title": "Spatial transcriptomics v2 -> Lung atlas matrix",
+ "status": "needs-review",
+ "compatibilityScore": 80,
+ "findingTypes": [
+ "missing-lockfile-provenance",
+ "stale-runtime-capture"
+ ],
+ "nodes": [
+ {
+ "id": "protocol-spatial-v2",
+ "type": "protocol",
+ "title": "Spatial transcriptomics v2"
+ },
+ {
+ "id": "dataset-spatial-lung",
+ "type": "dataset",
+ "title": "Lung atlas matrix",
+ "format": {
+ "name": "loom",
+ "version": "3.0.0"
+ }
+ }
+ ]
+ }
+ ],
+ "suppressedPaths": [],
+ "curatorActions": [
+ {
+ "type": "attach-lockfile",
+ "target": "poetry.lock",
+ "reason": "Attach a package lockfile before this recommendation is treated as fully reproducible."
+ },
+ {
+ "type": "refresh-runtime-capture",
+ "target": "runner-991",
+ "reason": "Run a fresh environment capture so graph recommendations reflect current software compatibility."
+ }
+ ]
+ }
+ },
+ {
+ "scenario": "CRISPR screen graph path is ready for discovery",
+ "generatedAt": "2026-05-22T16:20:00Z",
+ "recommendationId": "rec-crispr-screen-dataset",
+ "decision": "show-recommendation",
+ "summary": {
+ "totalPathCount": 1,
+ "suppressedPathCount": 0,
+ "reviewPathCount": 0,
+ "visiblePathCount": 1,
+ "findingCount": 0,
+ "actionCount": 0
+ },
+ "findings": [],
+ "curatorActions": [],
+ "suppressedPaths": [],
+ "visiblePaths": [
+ {
+ "id": "path-crispr-screen",
+ "from": "concept-crispr-screen",
+ "to": "dataset-crispr-2026",
+ "title": "CRISPR pooled screen -> CRISPR screen 2026",
+ "status": "visible",
+ "compatibilityScore": 100,
+ "findingTypes": [],
+ "nodes": [
+ {
+ "id": "concept-crispr-screen",
+ "type": "concept",
+ "title": "CRISPR pooled screen"
+ },
+ {
+ "id": "dataset-crispr-2026",
+ "type": "dataset",
+ "title": "CRISPR screen 2026",
+ "format": {
+ "name": "zarr",
+ "version": "2.16.1"
+ }
+ }
+ ]
+ }
+ ],
+ "entityPagePacket": {
+ "recommendationId": "rec-crispr-screen-dataset",
+ "decision": "show-recommendation",
+ "generatedAt": "2026-05-22T16:20:00Z",
+ "visiblePaths": [
+ {
+ "id": "path-crispr-screen",
+ "from": "concept-crispr-screen",
+ "to": "dataset-crispr-2026",
+ "title": "CRISPR pooled screen -> CRISPR screen 2026",
+ "status": "visible",
+ "compatibilityScore": 100,
+ "findingTypes": [],
+ "nodes": [
+ {
+ "id": "concept-crispr-screen",
+ "type": "concept",
+ "title": "CRISPR pooled screen"
+ },
+ {
+ "id": "dataset-crispr-2026",
+ "type": "dataset",
+ "title": "CRISPR screen 2026",
+ "format": {
+ "name": "zarr",
+ "version": "2.16.1"
+ }
+ }
+ ]
+ }
+ ],
+ "suppressedPaths": [],
+ "curatorActions": []
+ }
+ }
+]
diff --git a/knowledge-graph-software-version-compatibility-guard/reports/demo.mp4 b/knowledge-graph-software-version-compatibility-guard/reports/demo.mp4
new file mode 100644
index 00000000..208e1af9
Binary files /dev/null and b/knowledge-graph-software-version-compatibility-guard/reports/demo.mp4 differ
diff --git a/knowledge-graph-software-version-compatibility-guard/reports/software-compatibility-review.md b/knowledge-graph-software-version-compatibility-guard/reports/software-compatibility-review.md
new file mode 100644
index 00000000..b6be875f
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/reports/software-compatibility-review.md
@@ -0,0 +1,48 @@
+# Software Compatibility Review: rec-rna-velocity-notebook
+
+Decision: suppress-recommendation
+Generated: 2026-05-22T16:00:00Z
+
+## Visible Paths
+- None
+
+## Suppressed Paths
+- path-rna-velocity: python 3.8.18 does not satisfy >=3.10 <3.12 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. scanpy 1.8.2 does not satisfy >=1.10 <2.0 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. anndata 0.8.0 does not satisfy >=0.10 <0.11 for RNA velocity -> Velocity rerun notebook -> Cell Atlas v3. Container ghcr.io/scibase/velocity:latest is not pinned by digest. nextflow 22.10.6 does not satisfy >=23.10 <24.0.
+
+## Curator Actions
+- pin-compatible-runtime: python - Install python >=3.10 <3.12 before this graph path is recommended.
+- update-package-lock: scanpy - Install scanpy >=1.10 <2.0 before this graph path is recommended.
+- update-package-lock: anndata - Install anndata >=0.10 <0.11 before this graph path is recommended.
+- pin-container-digest: ghcr.io/scibase/velocity - Record an immutable sha256 digest before exposing this path in discovery recommendations.
+- upgrade-workflow-engine: nextflow - Pin nextflow >=23.10 <24.0 for rerunnable graph provenance.
+
+---
+# Software Compatibility Review: rec-spatial-transcriptomics-protocol
+
+Decision: curator-review
+Generated: 2026-05-22T16:10:00Z
+
+## Visible Paths
+- path-spatial-protocol: Spatial transcriptomics v2 -> Lung atlas matrix (score 80)
+
+## Suppressed Paths
+- None
+
+## Curator Actions
+- attach-lockfile: poetry.lock - Attach a package lockfile before this recommendation is treated as fully reproducible.
+- refresh-runtime-capture: runner-991 - Run a fresh environment capture so graph recommendations reflect current software compatibility.
+
+---
+# Software Compatibility Review: rec-crispr-screen-dataset
+
+Decision: show-recommendation
+Generated: 2026-05-22T16:20:00Z
+
+## Visible Paths
+- path-crispr-screen: CRISPR pooled screen -> CRISPR screen 2026 (score 100)
+
+## Suppressed Paths
+- None
+
+## Curator Actions
+- No curator action required
diff --git a/knowledge-graph-software-version-compatibility-guard/reports/summary.svg b/knowledge-graph-software-version-compatibility-guard/reports/summary.svg
new file mode 100644
index 00000000..0dddae43
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/reports/summary.svg
@@ -0,0 +1,23 @@
+
diff --git a/knowledge-graph-software-version-compatibility-guard/requirements-map.md b/knowledge-graph-software-version-compatibility-guard/requirements-map.md
new file mode 100644
index 00000000..79c6496b
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/requirements-map.md
@@ -0,0 +1,17 @@
+# Requirements Map
+
+Issue #17 asks for a Scientific Knowledge Graph that can turn research objects into structured graph intelligence with trustable navigation and recommendations. This slice focuses on software and runtime compatibility as a graph-safety layer.
+
+| Issue capability | Implementation coverage |
+| --- | --- |
+| Tools, instruments, and software libraries as extracted entities | `paths[].software`, `paths[].containers`, and `paths[].workflows` model software, runtime, container, and workflow engine nodes attached to graph paths. |
+| Linked data and schema-compatible metadata | `entityPagePacket` returns typed recommendation metadata with visible paths, suppressed paths, findings, and curator actions for entity pages. |
+| Knowledge navigation across concepts, datasets, notebooks, and protocols | Synthetic scenarios connect concepts, notebooks, datasets, and protocols through recommendation paths. |
+| AI research recommendations | `evaluateSoftwareVersionCompatibility` decides whether a recommendation should be shown, hidden, or routed to curator review. |
+| Evidence-backed relationship quality | Findings include incompatible version ranges, missing immutable container digests, stale runtime captures, and missing lockfile provenance. |
+| Reviewer-ready demo | `demo.js` writes JSON, Markdown, SVG, and H.264 MP4 artifacts in `reports/`. |
+| Safe local verification | `test.js` covers blocked, curator-review, and visible path outcomes with no external calls or secrets. |
+
+## Distinctness
+
+This module avoids duplicating existing issue #17 slices by staying narrow: it does not implement a broad extractor, navigator, ontology drift guard, link audit, evidence freshness module, instrument-method compatibility check, reproducibility route planner, recommendation visibility/diversity guard, clinical trial registry guard, ethics provenance guard, or funder lineage guard. It validates whether the software/runtime layer behind a graph path is compatible enough to expose that recommendation.
diff --git a/knowledge-graph-software-version-compatibility-guard/sample-data.js b/knowledge-graph-software-version-compatibility-guard/sample-data.js
new file mode 100644
index 00000000..eefe7851
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/sample-data.js
@@ -0,0 +1,80 @@
+const scenarios = [
+ {
+ name: 'RNA velocity notebook has incompatible runtime stack',
+ generatedAt: '2026-05-22T16:00:00Z',
+ recommendationId: 'rec-rna-velocity-notebook',
+ userContext: {institutionId: 'inst-northbridge', allowPreRelease: false},
+ paths: [
+ {
+ id: 'path-rna-velocity',
+ from: 'concept-rna-velocity',
+ to: 'notebook-velocity-2024',
+ rank: 1,
+ nodes: [
+ {id: 'concept-rna-velocity', type: 'concept', title: 'RNA velocity'},
+ {id: 'notebook-velocity-2024', type: 'notebook', title: 'Velocity rerun notebook'},
+ {id: 'dataset-cellatlas-v3', type: 'dataset', title: 'Cell Atlas v3', format: {name: 'h5ad', version: '0.9.2'}},
+ ],
+ software: [
+ {name: 'python', installed: '3.8.18', required: '>=3.10 <3.12', source: 'runtime'},
+ {name: 'scanpy', installed: '1.8.2', required: '>=1.10 <2.0', source: 'lockfile'},
+ {name: 'anndata', installed: '0.8.0', required: '>=0.10 <0.11', source: 'dataset metadata'},
+ ],
+ containers: [{image: 'ghcr.io/scibase/velocity', tag: 'latest', digest: ''}],
+ workflows: [{engine: 'nextflow', installed: '22.10.6', required: '>=23.10 <24.0'}],
+ evidence: [{type: 'lockfile', id: 'poetry.lock', present: true}, {type: 'runtime-capture', id: 'runner-884', capturedAt: '2026-01-08'}],
+ },
+ ],
+ },
+ {
+ name: 'Spatial protocol is compatible but provenance needs curator review',
+ generatedAt: '2026-05-22T16:10:00Z',
+ recommendationId: 'rec-spatial-transcriptomics-protocol',
+ paths: [
+ {
+ id: 'path-spatial-protocol',
+ from: 'protocol-spatial-v2',
+ to: 'dataset-spatial-lung',
+ rank: 2,
+ nodes: [
+ {id: 'protocol-spatial-v2', type: 'protocol', title: 'Spatial transcriptomics v2'},
+ {id: 'dataset-spatial-lung', type: 'dataset', title: 'Lung atlas matrix', format: {name: 'loom', version: '3.0.0'}},
+ ],
+ software: [
+ {name: 'python', installed: '3.11.6', required: '>=3.10 <3.12', source: 'runtime'},
+ {name: 'loompy', installed: '3.0.7', required: '>=3.0 <4.0', source: 'environment.yml'},
+ ],
+ containers: [{image: 'ghcr.io/scibase/spatial', tag: '2026-02', digest: 'sha256:1234'}],
+ workflows: [{engine: 'snakemake', installed: '8.5.3', required: '>=8.0 <9.0'}],
+ evidence: [{type: 'lockfile', id: 'poetry.lock', present: false}, {type: 'runtime-capture', id: 'runner-991', capturedAt: '2025-08-01'}],
+ },
+ ],
+ },
+ {
+ name: 'CRISPR screen graph path is ready for discovery',
+ generatedAt: '2026-05-22T16:20:00Z',
+ recommendationId: 'rec-crispr-screen-dataset',
+ paths: [
+ {
+ id: 'path-crispr-screen',
+ from: 'concept-crispr-screen',
+ to: 'dataset-crispr-2026',
+ rank: 1,
+ nodes: [
+ {id: 'concept-crispr-screen', type: 'concept', title: 'CRISPR pooled screen'},
+ {id: 'dataset-crispr-2026', type: 'dataset', title: 'CRISPR screen 2026', format: {name: 'zarr', version: '2.16.1'}},
+ ],
+ software: [
+ {name: 'python', installed: '3.11.8', required: '>=3.10 <3.12', source: 'runtime'},
+ {name: 'zarr', installed: '2.16.1', required: '>=2.15 <3.0', source: 'lockfile'},
+ {name: 'pandas', installed: '2.2.2', required: '>=2.1 <3.0', source: 'lockfile'},
+ ],
+ containers: [{image: 'ghcr.io/scibase/crispr', tag: '2026-05', digest: 'sha256:abcdef'}],
+ workflows: [{engine: 'nextflow', installed: '23.10.1', required: '>=23.10 <24.0'}],
+ evidence: [{type: 'lockfile', id: 'poetry.lock', present: true}, {type: 'runtime-capture', id: 'runner-1201', capturedAt: '2026-05-12'}],
+ },
+ ],
+ },
+];
+
+module.exports = {scenarios};
diff --git a/knowledge-graph-software-version-compatibility-guard/test.js b/knowledge-graph-software-version-compatibility-guard/test.js
new file mode 100644
index 00000000..49d36c9f
--- /dev/null
+++ b/knowledge-graph-software-version-compatibility-guard/test.js
@@ -0,0 +1,121 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const {
+ evaluateSoftwareVersionCompatibility,
+ buildSoftwareCompatibilityReviewPacket,
+} = require('./index');
+
+test('suppresses graph recommendations when software and runtime versions cannot satisfy an edge', () => {
+ const result = evaluateSoftwareVersionCompatibility({
+ generatedAt: '2026-05-22T16:00:00Z',
+ recommendationId: 'rec-rna-velocity-notebook',
+ userContext: {institutionId: 'inst-northbridge', allowPreRelease: false},
+ paths: [
+ {
+ id: 'path-rna-velocity',
+ from: 'concept-rna-velocity',
+ to: 'notebook-velocity-2024',
+ rank: 1,
+ nodes: [
+ {id: 'concept-rna-velocity', type: 'concept', title: 'RNA velocity'},
+ {id: 'notebook-velocity-2024', type: 'notebook', title: 'Velocity rerun notebook'},
+ {id: 'dataset-cellatlas-v3', type: 'dataset', title: 'Cell Atlas v3', format: {name: 'h5ad', version: '0.9.2'}},
+ ],
+ software: [
+ {name: 'python', installed: '3.8.18', required: '>=3.10 <3.12', source: 'runtime'},
+ {name: 'scanpy', installed: '1.8.2', required: '>=1.10 <2.0', source: 'lockfile'},
+ {name: 'anndata', installed: '0.8.0', required: '>=0.10 <0.11', source: 'dataset metadata'},
+ ],
+ containers: [{image: 'ghcr.io/scibase/velocity', tag: 'latest', digest: ''}],
+ workflows: [{engine: 'nextflow', installed: '22.10.6', required: '>=23.10 <24.0'}],
+ evidence: [{type: 'lockfile', id: 'poetry.lock', present: true}, {type: 'runtime-capture', id: 'runner-884', capturedAt: '2026-01-08'}],
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'suppress-recommendation');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ [
+ 'runtime-version-incompatible',
+ 'package-version-incompatible',
+ 'package-version-incompatible',
+ 'container-digest-missing',
+ 'workflow-engine-incompatible',
+ ]
+ );
+ assert.equal(result.summary.suppressedPathCount, 1);
+ assert.match(result.suppressedPaths[0].reason, /python 3\.8\.18/);
+ assert.equal(result.curatorActions[0].type, 'pin-compatible-runtime');
+});
+
+test('routes weak software provenance to curator review without hiding otherwise compatible graph paths', () => {
+ const result = evaluateSoftwareVersionCompatibility({
+ generatedAt: '2026-05-22T16:10:00Z',
+ recommendationId: 'rec-spatial-transcriptomics-protocol',
+ paths: [
+ {
+ id: 'path-spatial-protocol',
+ from: 'protocol-spatial-v2',
+ to: 'dataset-spatial-lung',
+ rank: 2,
+ nodes: [
+ {id: 'protocol-spatial-v2', type: 'protocol', title: 'Spatial transcriptomics v2'},
+ {id: 'dataset-spatial-lung', type: 'dataset', title: 'Lung atlas matrix', format: {name: 'loom', version: '3.0.0'}},
+ ],
+ software: [
+ {name: 'python', installed: '3.11.6', required: '>=3.10 <3.12', source: 'runtime'},
+ {name: 'loompy', installed: '3.0.7', required: '>=3.0 <4.0', source: 'environment.yml'},
+ ],
+ containers: [{image: 'ghcr.io/scibase/spatial', tag: '2026-02', digest: 'sha256:1234'}],
+ workflows: [{engine: 'snakemake', installed: '8.5.3', required: '>=8.0 <9.0'}],
+ evidence: [{type: 'lockfile', id: 'poetry.lock', present: false}, {type: 'runtime-capture', id: 'runner-991', capturedAt: '2025-08-01'}],
+ },
+ ],
+ });
+
+ assert.equal(result.decision, 'curator-review');
+ assert.deepEqual(
+ result.findings.map((finding) => finding.type),
+ ['missing-lockfile-provenance', 'stale-runtime-capture']
+ );
+ assert.equal(result.summary.reviewPathCount, 1);
+ assert.equal(result.summary.suppressedPathCount, 0);
+ assert.equal(result.visiblePaths[0].id, 'path-spatial-protocol');
+});
+
+test('approves compatible version paths and builds an entity-page review packet', () => {
+ const result = evaluateSoftwareVersionCompatibility({
+ generatedAt: '2026-05-22T16:20:00Z',
+ recommendationId: 'rec-crispr-screen-dataset',
+ paths: [
+ {
+ id: 'path-crispr-screen',
+ from: 'concept-crispr-screen',
+ to: 'dataset-crispr-2026',
+ rank: 1,
+ nodes: [
+ {id: 'concept-crispr-screen', type: 'concept', title: 'CRISPR pooled screen'},
+ {id: 'dataset-crispr-2026', type: 'dataset', title: 'CRISPR screen 2026', format: {name: 'zarr', version: '2.16.1'}},
+ ],
+ software: [
+ {name: 'python', installed: '3.11.8', required: '>=3.10 <3.12', source: 'runtime'},
+ {name: 'zarr', installed: '2.16.1', required: '>=2.15 <3.0', source: 'lockfile'},
+ {name: 'pandas', installed: '2.2.2', required: '>=2.1 <3.0', source: 'lockfile'},
+ ],
+ containers: [{image: 'ghcr.io/scibase/crispr', tag: '2026-05', digest: 'sha256:abcdef'}],
+ workflows: [{engine: 'nextflow', installed: '23.10.1', required: '>=23.10 <24.0'}],
+ evidence: [{type: 'lockfile', id: 'poetry.lock', present: true}, {type: 'runtime-capture', id: 'runner-1201', capturedAt: '2026-05-12'}],
+ },
+ ],
+ });
+ const packet = buildSoftwareCompatibilityReviewPacket(result);
+
+ assert.equal(result.decision, 'show-recommendation');
+ assert.equal(result.findings.length, 0);
+ assert.equal(result.visiblePaths[0].compatibilityScore, 100);
+ assert.equal(result.entityPagePacket.recommendationId, 'rec-crispr-screen-dataset');
+ assert.match(packet, /CRISPR screen 2026/);
+ assert.match(packet, /No curator action required/);
+});