From c4b384cf8e1ba5b143d2543d33d2dd6d1aca2083 Mon Sep 17 00:00:00 2001 From: "tho.nguyen" <91511523+haki203@users.noreply.github.com> Date: Fri, 22 May 2026 22:27:04 +0700 Subject: [PATCH] Add knowledge graph software compatibility guard --- .../README.md | 30 ++ .../demo.js | 148 ++++++++ .../index.js | 331 +++++++++++++++++ .../reports/compatibility-review.json | 343 ++++++++++++++++++ .../reports/demo.mp4 | Bin 0 -> 6614 bytes .../reports/software-compatibility-review.md | 48 +++ .../reports/summary.svg | 23 ++ .../requirements-map.md | 17 + .../sample-data.js | 80 ++++ .../test.js | 121 ++++++ 10 files changed, 1141 insertions(+) create mode 100644 knowledge-graph-software-version-compatibility-guard/README.md create mode 100644 knowledge-graph-software-version-compatibility-guard/demo.js create mode 100644 knowledge-graph-software-version-compatibility-guard/index.js create mode 100644 knowledge-graph-software-version-compatibility-guard/reports/compatibility-review.json create mode 100644 knowledge-graph-software-version-compatibility-guard/reports/demo.mp4 create mode 100644 knowledge-graph-software-version-compatibility-guard/reports/software-compatibility-review.md create mode 100644 knowledge-graph-software-version-compatibility-guard/reports/summary.svg create mode 100644 knowledge-graph-software-version-compatibility-guard/requirements-map.md create mode 100644 knowledge-graph-software-version-compatibility-guard/sample-data.js create mode 100644 knowledge-graph-software-version-compatibility-guard/test.js 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 0000000000000000000000000000000000000000..208e1af920d2ad60160b6f7b5d11514587fb63fe GIT binary patch literal 6614 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 literal 0 HcmV?d00001 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/); +});