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 = `
+`;
+
+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 @@
+
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/);
+});