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 = ` + + Software Version Compatibility Graph Guard + Preflight runtime, package, container, workflow, and lockfile evidence before graph recommendations show + + + Suppressed + ${decisionCounts['suppress-recommendation'] || 0} + + + + Curator Review + ${decisionCounts['curator-review'] || 0} + + + + Visible + ${decisionCounts['show-recommendation'] || 0} + + Paths: ${totals.paths} | Suppressed: ${totals.suppressed} | Review: ${totals.review} | Findings: ${totals.findings} | Actions: ${totals.actions} + Checks: semver ranges, immutable container digests, workflow engine ranges, lockfile presence, runtime capture freshness + Synthetic graph fixtures only. No package registry, container registry, dataset, credential, or network calls. + +`; + +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 @@ + + + Software Version Compatibility Graph Guard + Preflight runtime, package, container, workflow, and lockfile evidence before graph recommendations show + + + Suppressed + 1 + + + + Curator Review + 1 + + + + Visible + 1 + + Paths: 3 | Suppressed: 1 | Review: 1 | Findings: 7 | Actions: 7 + Checks: semver ranges, immutable container digests, workflow engine ranges, lockfile presence, runtime capture freshness + Synthetic graph fixtures only. No package registry, container registry, dataset, credential, or network calls. + 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/); +});