diff --git a/research-materials-authentication-assistant/README.md b/research-materials-authentication-assistant/README.md new file mode 100644 index 00000000..7d54a83a --- /dev/null +++ b/research-materials-authentication-assistant/README.md @@ -0,0 +1,54 @@ +# Research Materials Authentication Assistant + +This module adds a focused research-materials authentication guard for the AI-Powered Research Assistant Suite in issue `#16`. + +AI-assisted review can miss mundane but decisive reproducibility problems: an antibody without a traceable identifier, a stale validation panel, a contaminated cell line, a species mismatch, or a passage range that cannot be true. This assistant inspects synthetic manuscript review packets and returns release, review, or hold decisions with evidence-linked remediation. + +## Scope + +- Checks RRID-style identifiers for antibodies, cell lines, organism strains, primary cells, and reagents. +- Flags missing lot or batch metadata for lot-sensitive materials. +- Checks validation evidence freshness against a deterministic review date. +- Holds contaminated cell-derived materials. +- Checks reported passage ranges and reported passage numbers. +- Compares material species and claimed organism against linked assays. +- Holds antibody target mismatches when the antibody target does not match the measured assay target. +- Produces JSON, Markdown, SVG, and MP4 demo artifacts. +- Uses synthetic data only. +- Uses no external AI APIs, credentials, private manuscripts, payment systems, or network calls. + +## Requirement Map + +| Issue #16 capability | Implementation | +| --- | --- | +| Auto peer review reports | Emits structured findings, severity, decision, confidence, and remediation. | +| Methodological and reproducibility red flags | Detects identifier gaps, stale validation, contamination, passage errors, organism mismatch, and antibody target mismatch. | +| Adaptive templates per domain | Supports packet-level `domain` and domain-specific reviewer notes. | +| Claims vs evidence alignment | Compares material identity and validation evidence against linked assays and manuscript claims. | +| Reproducible deterministic output | Demo and tests use fixed synthetic packets and dependency-free scoring. | + +## Run + +```bash +npm test +npm run demo +npm run video +npm run check +``` + +Demo artifacts are written to `reports/`: + +- `summary.json` +- `reviewer-packet.md` +- `summary.svg` +- `demo.mp4` + +## Decisions + +- `RELEASE`: materials are traceable, authenticated, fresh, and aligned with the linked assays. +- `REVIEW`: a reviewer should inspect missing identifiers, stale validation, species mismatch, or incomplete material provenance. +- `HOLD`: release is blocked by critical reproducibility problems such as contamination, impossible passage metadata, or antibody target mismatch. + +## AI Assistance Disclosure + +Implemented with Codex assistance and locally verified with deterministic tests and generated artifacts. diff --git a/research-materials-authentication-assistant/data/sample_material_packets.json b/research-materials-authentication-assistant/data/sample_material_packets.json new file mode 100644 index 00000000..0c442fa4 --- /dev/null +++ b/research-materials-authentication-assistant/data/sample_material_packets.json @@ -0,0 +1,170 @@ +[ + { + "id": "clean-organoid-panel", + "title": "Authenticated organoid cytokine response panel", + "domain": "translational immunology", + "reviewDate": "2026-08-15", + "materials": [ + { + "name": "HBEC-Alpha authenticated airway cell line", + "type": "cell_line", + "role": "model system", + "rrid": "RRID:CVCL_SYN1", + "lot": "BANK-2026-07-A", + "source": "institutional biobank", + "species": "human", + "claimedOrganism": "human", + "validationEvidence": ["STR profile match", "certificate of analysis", "mycoplasma PCR negative"], + "validationDate": "2026-07-10", + "contaminationStatus": "negative", + "passageRange": { "min": 5, "max": 18 }, + "reportedPassage": 9 + }, + { + "name": "Anti-IL6 clone S6", + "type": "antibody", + "role": "capture antibody", + "rrid": "RRID:AB_9000001", + "lot": "LOT-IL6-2606", + "source": "validated synthetic catalog", + "species": "human", + "target": "IL6", + "assayTargets": ["IL6"], + "validationEvidence": ["western blot specificity", "orthogonal ELISA spike recovery"], + "validationDate": "2026-06-14", + "contaminationStatus": "not_detected" + } + ], + "assays": [ + { + "name": "IL6 secretion ELISA", + "materialName": "Anti-IL6 clone S6", + "organism": "human", + "target": "IL6", + "readout": "secreted cytokine concentration" + } + ], + "manuscriptClaims": [ + "All primary materials were identity checked before cytokine-response analysis." + ] + }, + { + "id": "review-stale-rrid-organism", + "title": "Stale antibody validation with organism mismatch", + "domain": "cell signaling", + "reviewDate": "2026-08-15", + "materials": [ + { + "name": "Anti-pERK screening antibody", + "type": "antibody", + "role": "western blot antibody", + "rrid": "", + "lot": "WB-ERK-2019", + "source": "legacy freezer box", + "species": "mouse", + "claimedOrganism": "human", + "target": "pERK", + "assayTargets": ["pERK"], + "validationEvidence": ["vendor blot from 2019"], + "validationDate": "2019-03-04", + "contaminationStatus": "not_detected" + } + ], + "assays": [ + { + "name": "Human pERK western blot", + "materialName": "Anti-pERK screening antibody", + "organism": "human", + "target": "pERK", + "readout": "phosphorylated ERK band intensity" + } + ], + "manuscriptClaims": [ + "Human samples were assayed with pathway-specific antibody material." + ] + }, + { + "id": "hold-contaminated-line", + "title": "Contaminated cell line in proliferation assay", + "domain": "cancer biology", + "reviewDate": "2026-08-15", + "materials": [ + { + "name": "MCB-Tumor-2 working stock", + "type": "cell_line", + "role": "tumor model", + "rrid": "RRID:CVCL_SYN2", + "lot": "CELL-2026-05-B", + "source": "working stock", + "species": "human", + "claimedOrganism": "human", + "validationEvidence": ["STR profile match"], + "validationDate": "2026-05-12", + "contaminationStatus": "mycoplasma_positive", + "passageRange": { "min": 3, "max": 14 }, + "reportedPassage": 11 + } + ], + "assays": [ + { + "name": "Proliferation growth curve", + "materialName": "MCB-Tumor-2 working stock", + "organism": "human", + "target": "cell growth", + "readout": "confluence over time" + } + ], + "manuscriptClaims": [ + "The tumor cell model supports the proliferation finding." + ] + }, + { + "id": "hold-antibody-passage", + "title": "Antibody target mismatch and impossible passage metadata", + "domain": "neurobiology", + "reviewDate": "2026-08-15", + "materials": [ + { + "name": "NeuroStem-X passage record", + "type": "primary_cell", + "role": "differentiation model", + "rrid": "RRID:CVCL_SYN3", + "lot": "NSX-2601", + "source": "synthetic stem-cell bank", + "species": "human", + "claimedOrganism": "human", + "validationEvidence": ["identity PCR panel"], + "validationDate": "2026-01-15", + "contaminationStatus": "negative", + "passageRange": { "min": 22, "max": 8 }, + "reportedPassage": 31 + }, + { + "name": "Anti-GFAP clone G4", + "type": "antibody", + "role": "immunostaining antibody", + "rrid": "RRID:AB_9000002", + "lot": "LOT-GFAP-2601", + "source": "validated synthetic catalog", + "species": "human", + "target": "GFAP", + "assayTargets": ["GFAP"], + "validationEvidence": ["knockdown specificity panel"], + "validationDate": "2026-01-20", + "contaminationStatus": "not_detected" + } + ], + "assays": [ + { + "name": "MAP2 neuron staining", + "materialName": "Anti-GFAP clone G4", + "organism": "human", + "target": "MAP2", + "readout": "fluorescent neurite area" + } + ], + "manuscriptClaims": [ + "Neuron differentiation was quantified with validated immunostaining material." + ] + } +] diff --git a/research-materials-authentication-assistant/package.json b/research-materials-authentication-assistant/package.json new file mode 100644 index 00000000..2ae00e43 --- /dev/null +++ b/research-materials-authentication-assistant/package.json @@ -0,0 +1,13 @@ +{ + "name": "research-materials-authentication-assistant", + "version": "1.0.0", + "description": "Dependency-free research materials authentication guard for AI research assistant review packets.", + "main": "src/index.js", + "scripts": { + "test": "node --test", + "demo": "node scripts/demo.js", + "video": "node scripts/render-demo-video.js", + "check": "node --check src/index.js && node --check scripts/demo.js && node --check scripts/render-demo-video.js" + }, + "license": "MIT" +} diff --git a/research-materials-authentication-assistant/reports/demo.mp4 b/research-materials-authentication-assistant/reports/demo.mp4 new file mode 100644 index 00000000..11770451 Binary files /dev/null and b/research-materials-authentication-assistant/reports/demo.mp4 differ diff --git a/research-materials-authentication-assistant/reports/reviewer-packet.md b/research-materials-authentication-assistant/reports/reviewer-packet.md new file mode 100644 index 00000000..d1b9efd9 --- /dev/null +++ b/research-materials-authentication-assistant/reports/reviewer-packet.md @@ -0,0 +1,56 @@ +# Research Materials Authentication Review + +Generated: 2026-08-15T20:45:50.858Z + +## Decision Counts + +- RELEASE: 1 +- REVIEW: 1 +- HOLD: 2 + +## Packet Reviews + +### Authenticated organoid cytokine response panel +Decision: RELEASE +Confidence: 0.96 +Materials checked: 2 +Assays checked: 1 +Reviewer note: translational immunology: materials are traceable, authenticated, and aligned with linked assays. + +### Stale antibody validation with organism mismatch +Decision: REVIEW +Confidence: 0.12 +Materials checked: 1 +Assays checked: 1 +Reviewer note: cell signaling: REVIEW before release because a tracked research material is missing a valid rrid-style identifier. +- MAJOR MISSING_OR_INVALID_RRID: A tracked research material is missing a valid RRID-style identifier. + Remediation: Add the RRID or equivalent registry identifier used for the exact material instance. +- MAJOR STALE_VALIDATION_DATE: The latest validation evidence is older than the configured freshness window. + Remediation: Refresh material authentication or justify why the prior validation still applies. +- MAJOR MATERIAL_CLAIM_ORGANISM_MISMATCH: The material species does not match the organism claimed in the manuscript packet. + Remediation: Correct the claimed organism or provide a cross-species rationale and validation evidence. +- MAJOR ASSAY_ORGANISM_MISMATCH: The material species does not match the organism declared for the linked assay. + Remediation: Route the packet for reviewer confirmation or correct the assay-material mapping. + +### Contaminated cell line in proliferation assay +Decision: HOLD +Confidence: 0.61 +Materials checked: 1 +Assays checked: 1 +Reviewer note: cancer biology: HOLD before release because a cell-derived material is marked contaminated or contamination-positive. +- CRITICAL CONTAMINATED_CELL_MATERIAL: A cell-derived material is marked contaminated or contamination-positive. + Remediation: Hold release until the assay is repeated with a contamination-negative authenticated material. + +### Antibody target mismatch and impossible passage metadata +Decision: HOLD +Confidence: 0.1 +Materials checked: 2 +Assays checked: 1 +Reviewer note: neurobiology: HOLD before release because the packet reports an impossible or biologically implausible passage range. +- CRITICAL IMPOSSIBLE_PASSAGE_RANGE: The packet reports an impossible or biologically implausible passage range. + Remediation: Correct the passage metadata and provide source authentication records before the assistant releases the review. +- CRITICAL ANTIBODY_TARGET_MISMATCH: The antibody target does not match the target measured by the linked assay. + Remediation: Hold release until the antibody, assay target, or interpretation is corrected and revalidated. +- MAJOR ASSAY_TARGET_NOT_VALIDATED_FOR_MATERIAL: The assay target is outside the target set validated for this material. + Remediation: Add target-specific validation evidence or remap the assay to an authenticated material. + diff --git a/research-materials-authentication-assistant/reports/summary.json b/research-materials-authentication-assistant/reports/summary.json new file mode 100644 index 00000000..8e708bd3 --- /dev/null +++ b/research-materials-authentication-assistant/reports/summary.json @@ -0,0 +1,156 @@ +{ + "generatedAt": "2026-08-15T20:45:50.858Z", + "packetCount": 4, + "decisionCounts": { + "RELEASE": 1, + "REVIEW": 1, + "HOLD": 2 + }, + "reviews": [ + { + "id": "clean-organoid-panel", + "title": "Authenticated organoid cytokine response panel", + "domain": "translational immunology", + "decision": "RELEASE", + "confidence": 0.96, + "materialCount": 2, + "assayCount": 1, + "findingCount": 0, + "findings": [], + "reviewerNote": "translational immunology: materials are traceable, authenticated, and aligned with linked assays." + }, + { + "id": "review-stale-rrid-organism", + "title": "Stale antibody validation with organism mismatch", + "domain": "cell signaling", + "decision": "REVIEW", + "confidence": 0.12, + "materialCount": 1, + "assayCount": 1, + "findingCount": 4, + "findings": [ + { + "code": "MISSING_OR_INVALID_RRID", + "severity": "major", + "message": "A tracked research material is missing a valid RRID-style identifier.", + "evidence": { + "material": "Anti-pERK screening antibody", + "type": "antibody", + "rrid": null + }, + "remediation": "Add the RRID or equivalent registry identifier used for the exact material instance." + }, + { + "code": "STALE_VALIDATION_DATE", + "severity": "major", + "message": "The latest validation evidence is older than the configured freshness window.", + "evidence": { + "material": "Anti-pERK screening antibody", + "validationDate": "2019-03-04", + "staleAfterDays": 730 + }, + "remediation": "Refresh material authentication or justify why the prior validation still applies." + }, + { + "code": "MATERIAL_CLAIM_ORGANISM_MISMATCH", + "severity": "major", + "message": "The material species does not match the organism claimed in the manuscript packet.", + "evidence": { + "material": "Anti-pERK screening antibody", + "species": "mouse", + "claimedOrganism": "human" + }, + "remediation": "Correct the claimed organism or provide a cross-species rationale and validation evidence." + }, + { + "code": "ASSAY_ORGANISM_MISMATCH", + "severity": "major", + "message": "The material species does not match the organism declared for the linked assay.", + "evidence": { + "material": "Anti-pERK screening antibody", + "materialSpecies": "mouse", + "assay": "Human pERK western blot", + "assayOrganism": "human" + }, + "remediation": "Route the packet for reviewer confirmation or correct the assay-material mapping." + } + ], + "reviewerNote": "cell signaling: REVIEW before release because a tracked research material is missing a valid rrid-style identifier." + }, + { + "id": "hold-contaminated-line", + "title": "Contaminated cell line in proliferation assay", + "domain": "cancer biology", + "decision": "HOLD", + "confidence": 0.61, + "materialCount": 1, + "assayCount": 1, + "findingCount": 1, + "findings": [ + { + "code": "CONTAMINATED_CELL_MATERIAL", + "severity": "critical", + "message": "A cell-derived material is marked contaminated or contamination-positive.", + "evidence": { + "material": "MCB-Tumor-2 working stock", + "contaminationStatus": "mycoplasma_positive" + }, + "remediation": "Hold release until the assay is repeated with a contamination-negative authenticated material." + } + ], + "reviewerNote": "cancer biology: HOLD before release because a cell-derived material is marked contaminated or contamination-positive." + }, + { + "id": "hold-antibody-passage", + "title": "Antibody target mismatch and impossible passage metadata", + "domain": "neurobiology", + "decision": "HOLD", + "confidence": 0.1, + "materialCount": 2, + "assayCount": 1, + "findingCount": 3, + "findings": [ + { + "code": "IMPOSSIBLE_PASSAGE_RANGE", + "severity": "critical", + "message": "The packet reports an impossible or biologically implausible passage range.", + "evidence": { + "material": "NeuroStem-X passage record", + "passageRange": { + "min": 22, + "max": 8 + } + }, + "remediation": "Correct the passage metadata and provide source authentication records before the assistant releases the review." + }, + { + "code": "ANTIBODY_TARGET_MISMATCH", + "severity": "critical", + "message": "The antibody target does not match the target measured by the linked assay.", + "evidence": { + "material": "Anti-GFAP clone G4", + "antibodyTarget": "GFAP", + "assay": "MAP2 neuron staining", + "assayTarget": "MAP2" + }, + "remediation": "Hold release until the antibody, assay target, or interpretation is corrected and revalidated." + }, + { + "code": "ASSAY_TARGET_NOT_VALIDATED_FOR_MATERIAL", + "severity": "major", + "message": "The assay target is outside the target set validated for this material.", + "evidence": { + "material": "Anti-GFAP clone G4", + "assay": "MAP2 neuron staining", + "assayTarget": "MAP2", + "validatedTargets": [ + "GFAP" + ] + }, + "remediation": "Add target-specific validation evidence or remap the assay to an authenticated material." + } + ], + "reviewerNote": "neurobiology: HOLD before release because the packet reports an impossible or biologically implausible passage range." + } + ] +} diff --git a/research-materials-authentication-assistant/reports/summary.svg b/research-materials-authentication-assistant/reports/summary.svg new file mode 100644 index 00000000..512a4c90 --- /dev/null +++ b/research-materials-authentication-assistant/reports/summary.svg @@ -0,0 +1,14 @@ + + + Research Materials Authentication + RRIDs, lots, validation dates, contamination checks, passage ranges, organism fit, and antibody target alignment. + RELEASE: 1 + + + REVIEW: 1 + + + HOLD: 2 + + + \ No newline at end of file diff --git a/research-materials-authentication-assistant/scripts/demo.js b/research-materials-authentication-assistant/scripts/demo.js new file mode 100644 index 00000000..934fcb68 --- /dev/null +++ b/research-materials-authentication-assistant/scripts/demo.js @@ -0,0 +1,76 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzePackets } = require("../src"); + +const root = path.resolve(__dirname, ".."); +const packets = JSON.parse(fs.readFileSync(path.join(root, "data", "sample_material_packets.json"), "utf8")); +const report = analyzePackets(packets); +const outDir = path.join(root, "reports"); + +fs.mkdirSync(outDir, { recursive: true }); +fs.writeFileSync(path.join(outDir, "summary.json"), `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(path.join(outDir, "reviewer-packet.md"), renderMarkdown(report)); +fs.writeFileSync(path.join(outDir, "summary.svg"), renderSvg(report)); + +console.log(JSON.stringify({ + packetCount: report.packetCount, + decisionCounts: report.decisionCounts, + reports: [ + "reports/summary.json", + "reports/reviewer-packet.md", + "reports/summary.svg" + ] +}, null, 2)); + +function renderMarkdown(report) { + const lines = [ + "# Research Materials Authentication Review", + "", + `Generated: ${report.generatedAt}`, + "", + "## Decision Counts", + "", + `- RELEASE: ${report.decisionCounts.RELEASE}`, + `- REVIEW: ${report.decisionCounts.REVIEW}`, + `- HOLD: ${report.decisionCounts.HOLD}`, + "", + "## Packet Reviews", + "" + ]; + + for (const review of report.reviews) { + lines.push(`### ${review.title}`); + lines.push(`Decision: ${review.decision}`); + lines.push(`Confidence: ${review.confidence}`); + lines.push(`Materials checked: ${review.materialCount}`); + lines.push(`Assays checked: ${review.assayCount}`); + lines.push(`Reviewer note: ${review.reviewerNote}`); + for (const item of review.findings) { + lines.push(`- ${item.severity.toUpperCase()} ${item.code}: ${item.message}`); + lines.push(` Remediation: ${item.remediation}`); + } + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvg(report) { + return ` + + Research Materials Authentication + RRIDs, lots, validation dates, contamination checks, passage ranges, organism fit, and antibody target alignment. + ${bar("RELEASE", report.decisionCounts.RELEASE, 48, 148, "#32b38c")} + ${bar("REVIEW", report.decisionCounts.REVIEW, 48, 218, "#d69d2e")} + ${bar("HOLD", report.decisionCounts.HOLD, 48, 288, "#d8526b")} +`; +} + +function bar(label, count, x, y, color) { + const width = 96 + count * 116; + return `${label}: ${count} + + `; +} diff --git a/research-materials-authentication-assistant/scripts/render-demo-video.js b/research-materials-authentication-assistant/scripts/render-demo-video.js new file mode 100644 index 00000000..106fe046 --- /dev/null +++ b/research-materials-authentication-assistant/scripts/render-demo-video.js @@ -0,0 +1,60 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = path.resolve(__dirname, ".."); +const ffmpeg = path.resolve( + root, + "..", + "..", + "tool_downloads", + "video_tools", + "node_modules", + "ffmpeg-static", + "ffmpeg.exe" +); +const outDir = path.join(root, "reports"); +const out = path.join(outDir, "demo.mp4"); +const font = "C\\:/Windows/Fonts/arial.ttf"; + +fs.mkdirSync(outDir, { recursive: true }); + +const draw = [ + `drawtext=fontfile=${font}:text='Research Materials Authentication':x=64:y=56:fontsize=44:fontcolor=black`, + `drawtext=fontfile=${font}:text='AI peer-review gate for biological materials identity':x=64:y=120:fontsize=25:fontcolor=0x344052`, + `drawtext=fontfile=${font}:text='Synthetic packets analyzed 4':x=64:y=210:fontsize=34:fontcolor=black`, + `drawtext=fontfile=${font}:text='RELEASE 1 REVIEW 1 HOLD 2':x=64:y=268:fontsize=38:fontcolor=0x245fd6`, + `drawtext=fontfile=${font}:text='Checks RRIDs lots validation dates contamination passage ranges':x=64:y=358:fontsize=26:fontcolor=0x344052`, + `drawtext=fontfile=${font}:text='Flags organism mismatch antibody target mismatch stale evidence':x=64:y=422:fontsize=26:fontcolor=0x344052`, + `drawtext=fontfile=${font}:text='No external APIs credentials private manuscripts or live data':x=64:y=486:fontsize=26:fontcolor=0x344052` +].join(","); + +if (!fs.existsSync(ffmpeg)) { + throw new Error(`ffmpeg binary not found at ${ffmpeg}`); +} + +const result = spawnSync(ffmpeg, [ + "-y", + "-f", + "lavfi", + "-i", + "color=c=0xf7f9fb:s=1280x720:d=8:r=30", + "-vf", + draw, + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + out +], { encoding: "utf8" }); + +if (result.status !== 0) { + process.stderr.write(result.stderr || result.stdout); + process.exit(result.status || 1); +} + +console.log(JSON.stringify({ out, bytes: fs.statSync(out).size }, null, 2)); diff --git a/research-materials-authentication-assistant/src/index.js b/research-materials-authentication-assistant/src/index.js new file mode 100644 index 00000000..959cc993 --- /dev/null +++ b/research-materials-authentication-assistant/src/index.js @@ -0,0 +1,333 @@ +"use strict"; + +const TRACKED_MATERIAL_TYPES = new Set([ + "antibody", + "cell_line", + "organism_strain", + "primary_cell", + "reagent" +]); + +const STALE_VALIDATION_DAYS = 730; +const CLEAN_CONTAMINATION_VALUES = new Set(["negative", "clear", "not_detected", "none"]); +const CONTAMINATION_ALERT_VALUES = new Set(["positive", "detected", "contaminated", "mycoplasma_positive"]); + +function finding(code, severity, message, evidence, remediation) { + return { code, severity, message, evidence, remediation }; +} + +function normalizeText(value) { + return String(value || "").trim().toLowerCase(); +} + +function normalizeMaterial(material) { + return { + name: material.name || material.id || "unnamed material", + type: normalizeText(material.type || "reagent"), + role: material.role || "unspecified", + rrid: material.rrid || "", + lot: material.lot || "", + source: material.source || "", + species: material.species || material.organism || "", + claimedOrganism: material.claimedOrganism || "", + target: material.target || "", + assayTargets: Array.isArray(material.assayTargets) ? material.assayTargets : [], + validationEvidence: Array.isArray(material.validationEvidence) ? material.validationEvidence : [], + validationDate: material.validationDate || "", + contaminationStatus: material.contaminationStatus || "", + passageRange: material.passageRange || null, + reportedPassage: material.reportedPassage, + metadata: material.metadata || {} + }; +} + +function normalizeAssay(assay) { + return { + name: assay.name || "unnamed assay", + materialName: assay.materialName || assay.material || "", + organism: assay.organism || assay.species || "", + target: assay.target || "", + readout: assay.readout || "" + }; +} + +function normalizePacket(packet) { + return { + id: packet.id, + title: packet.title || "Untitled research packet", + domain: packet.domain || "life sciences", + reviewDate: packet.reviewDate || "2026-08-15", + materials: Array.isArray(packet.materials) ? packet.materials.map(normalizeMaterial) : [], + assays: Array.isArray(packet.assays) ? packet.assays.map(normalizeAssay) : [], + manuscriptClaims: Array.isArray(packet.manuscriptClaims) ? packet.manuscriptClaims : [] + }; +} + +function validRrid(value) { + return /^RRID:[A-Za-z0-9_:-]+$/.test(String(value || "").trim()); +} + +function parseDate(value) { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time) : null; +} + +function daysBetween(start, end) { + return Math.floor((end.getTime() - start.getTime()) / 86400000); +} + +function sameConcept(left, right) { + if (!left || !right) return true; + return normalizeText(left) === normalizeText(right); +} + +function materialMatchesAssay(material, assay) { + return normalizeText(material.name) === normalizeText(assay.materialName); +} + +function assaysForMaterial(material, assays) { + return assays.filter((assay) => materialMatchesAssay(material, assay)); +} + +function analyzePassageRange(material) { + const findings = []; + const range = material.passageRange; + if (!range) return findings; + + const min = Number(range.min); + const max = Number(range.max); + const reported = material.reportedPassage === undefined ? null : Number(material.reportedPassage); + + if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min || max > 250) { + findings.push(finding( + "IMPOSSIBLE_PASSAGE_RANGE", + "critical", + "The packet reports an impossible or biologically implausible passage range.", + { material: material.name, passageRange: range }, + "Correct the passage metadata and provide source authentication records before the assistant releases the review." + )); + return findings; + } + + if (reported !== null && (!Number.isFinite(reported) || reported < min || reported > max)) { + findings.push(finding( + "PASSAGE_OUTSIDE_VALIDATED_RANGE", + "major", + "The reported passage is outside the validated passage range for this material.", + { material: material.name, passageRange: range, reportedPassage: material.reportedPassage }, + "Provide validation evidence for the reported passage or repeat the assay using a validated passage." + )); + } + + return findings; +} + +function analyzeMaterial(material, assays, reviewDate) { + const findings = []; + const linkedAssays = assaysForMaterial(material, assays); + + if (TRACKED_MATERIAL_TYPES.has(material.type) && !validRrid(material.rrid)) { + findings.push(finding( + "MISSING_OR_INVALID_RRID", + material.type === "reagent" ? "minor" : "major", + "A tracked research material is missing a valid RRID-style identifier.", + { material: material.name, type: material.type, rrid: material.rrid || null }, + "Add the RRID or equivalent registry identifier used for the exact material instance." + )); + } + + if ((material.type === "antibody" || material.type === "reagent") && !material.lot) { + findings.push(finding( + "MISSING_LOT_IDENTIFIER", + "minor", + "A lot-sensitive material is missing lot or batch information.", + { material: material.name, type: material.type }, + "Add lot, batch, clone, or catalog metadata so reviewers can trace the exact material." + )); + } + + if (TRACKED_MATERIAL_TYPES.has(material.type) && material.validationEvidence.length === 0) { + findings.push(finding( + "MISSING_VALIDATION_EVIDENCE", + "major", + "The packet does not include validation evidence for a tracked material.", + { material: material.name, type: material.type }, + "Attach identity, specificity, genotype, certificate-of-analysis, or orthogonal validation evidence." + )); + } + + const review = parseDate(reviewDate); + const validation = parseDate(material.validationDate); + if (!validation) { + findings.push(finding( + "MISSING_VALIDATION_DATE", + "major", + "The packet does not provide a parseable validation date.", + { material: material.name, validationDate: material.validationDate || null }, + "Provide the date of the latest identity or specificity validation." + )); + } else if (review && daysBetween(validation, review) > STALE_VALIDATION_DAYS) { + findings.push(finding( + "STALE_VALIDATION_DATE", + "major", + "The latest validation evidence is older than the configured freshness window.", + { material: material.name, validationDate: material.validationDate, staleAfterDays: STALE_VALIDATION_DAYS }, + "Refresh material authentication or justify why the prior validation still applies." + )); + } + + const contamination = normalizeText(material.contaminationStatus); + if (material.type === "cell_line" || material.type === "primary_cell") { + if (CONTAMINATION_ALERT_VALUES.has(contamination)) { + findings.push(finding( + "CONTAMINATED_CELL_MATERIAL", + "critical", + "A cell-derived material is marked contaminated or contamination-positive.", + { material: material.name, contaminationStatus: material.contaminationStatus }, + "Hold release until the assay is repeated with a contamination-negative authenticated material." + )); + } else if (!CLEAN_CONTAMINATION_VALUES.has(contamination)) { + findings.push(finding( + "CONTAMINATION_STATUS_UNVERIFIED", + "major", + "A cell-derived material lacks a clear contamination-negative status.", + { material: material.name, contaminationStatus: material.contaminationStatus || null }, + "Add recent mycoplasma and contamination testing before releasing the review." + )); + } + } + + if (material.species && material.claimedOrganism && !sameConcept(material.species, material.claimedOrganism)) { + findings.push(finding( + "MATERIAL_CLAIM_ORGANISM_MISMATCH", + "major", + "The material species does not match the organism claimed in the manuscript packet.", + { material: material.name, species: material.species, claimedOrganism: material.claimedOrganism }, + "Correct the claimed organism or provide a cross-species rationale and validation evidence." + )); + } + + for (const assay of linkedAssays) { + if (material.species && assay.organism && !sameConcept(material.species, assay.organism)) { + findings.push(finding( + "ASSAY_ORGANISM_MISMATCH", + "major", + "The material species does not match the organism declared for the linked assay.", + { material: material.name, materialSpecies: material.species, assay: assay.name, assayOrganism: assay.organism }, + "Route the packet for reviewer confirmation or correct the assay-material mapping." + )); + } + + if (material.type === "antibody" && material.target && assay.target && !sameConcept(material.target, assay.target)) { + findings.push(finding( + "ANTIBODY_TARGET_MISMATCH", + "critical", + "The antibody target does not match the target measured by the linked assay.", + { material: material.name, antibodyTarget: material.target, assay: assay.name, assayTarget: assay.target }, + "Hold release until the antibody, assay target, or interpretation is corrected and revalidated." + )); + } + + if (material.assayTargets.length > 0 && assay.target) { + const allowed = material.assayTargets.map(normalizeText); + if (!allowed.includes(normalizeText(assay.target))) { + findings.push(finding( + "ASSAY_TARGET_NOT_VALIDATED_FOR_MATERIAL", + "major", + "The assay target is outside the target set validated for this material.", + { material: material.name, assay: assay.name, assayTarget: assay.target, validatedTargets: material.assayTargets }, + "Add target-specific validation evidence or remap the assay to an authenticated material." + )); + } + } + } + + findings.push(...analyzePassageRange(material)); + return findings; +} + +function severityWeight(severity) { + if (severity === "critical") return 5; + if (severity === "major") return 3; + return 1; +} + +function decisionFromFindings(findings) { + if (findings.some((item) => item.severity === "critical")) return "HOLD"; + if (findings.length > 0) return "REVIEW"; + return "RELEASE"; +} + +function confidenceFromFindings(findings) { + const penalty = findings.reduce((total, item) => total + severityWeight(item.severity), 0); + return Math.max(0.1, Number((0.96 - penalty * 0.07).toFixed(2))); +} + +function reviewerNote(packet, decision, findings) { + if (decision === "RELEASE") { + return `${packet.domain}: materials are traceable, authenticated, and aligned with linked assays.`; + } + const first = findings[0]; + return `${packet.domain}: ${decision} before release because ${first.message.toLowerCase()}`; +} + +function analyzePacket(input) { + const packet = normalizePacket(input); + const reviewDate = packet.reviewDate; + const findings = []; + + if (packet.materials.length === 0) { + findings.push(finding( + "NO_MATERIALS_LISTED", + "major", + "The packet contains no research materials to authenticate.", + { materialCount: 0 }, + "Add a materials table with identifiers, source, validation, and assay mappings." + )); + } + + for (const material of packet.materials) { + findings.push(...analyzeMaterial(material, packet.assays, reviewDate)); + } + + const decision = decisionFromFindings(findings); + return { + id: packet.id, + title: packet.title, + domain: packet.domain, + decision, + confidence: confidenceFromFindings(findings), + materialCount: packet.materials.length, + assayCount: packet.assays.length, + findingCount: findings.length, + findings, + reviewerNote: reviewerNote(packet, decision, findings) + }; +} + +function analyzePackets(packets) { + const reviews = packets.map(analyzePacket); + const counts = reviews.reduce((acc, review) => { + acc[review.decision] = (acc[review.decision] || 0) + 1; + return acc; + }, {}); + + return { + generatedAt: new Date().toISOString(), + packetCount: reviews.length, + decisionCounts: { + RELEASE: counts.RELEASE || 0, + REVIEW: counts.REVIEW || 0, + HOLD: counts.HOLD || 0 + }, + reviews + }; +} + +module.exports = { + TRACKED_MATERIAL_TYPES, + STALE_VALIDATION_DAYS, + analyzePacket, + analyzePackets, + validRrid +}; diff --git a/research-materials-authentication-assistant/test/research-materials-authentication-assistant.test.js b/research-materials-authentication-assistant/test/research-materials-authentication-assistant.test.js new file mode 100644 index 00000000..c909acba --- /dev/null +++ b/research-materials-authentication-assistant/test/research-materials-authentication-assistant.test.js @@ -0,0 +1,182 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { analyzePacket, analyzePackets, validRrid } = require("../src"); + +test("validates RRID-style identifiers", () => { + assert.equal(validRrid("RRID:AB_123456"), true); + assert.equal(validRrid("RRID:CVCL_SYN1"), true); + assert.equal(validRrid("AB_123456"), false); +}); + +test("releases clean authenticated materials", () => { + const review = analyzePacket({ + id: "clean", + title: "Clean authenticated antibody assay", + reviewDate: "2026-08-15", + materials: [ + { + name: "Anti-TNF clone T1", + type: "antibody", + rrid: "RRID:AB_100001", + lot: "LOT-TNF-2601", + species: "human", + target: "TNF", + assayTargets: ["TNF"], + validationEvidence: ["knockdown specificity", "orthogonal ELISA"], + validationDate: "2026-06-01", + contaminationStatus: "not_detected" + } + ], + assays: [ + { name: "TNF ELISA", materialName: "Anti-TNF clone T1", organism: "human", target: "TNF" } + ] + }); + + assert.equal(review.decision, "RELEASE"); + assert.equal(review.findingCount, 0); +}); + +test("routes missing RRID, stale validation, and organism mismatch to review", () => { + const review = analyzePacket({ + id: "review", + title: "Review material identity", + reviewDate: "2026-08-15", + materials: [ + { + name: "Legacy anti-pERK", + type: "antibody", + rrid: "", + lot: "LOT-OLD", + species: "mouse", + claimedOrganism: "human", + target: "pERK", + assayTargets: ["pERK"], + validationEvidence: ["vendor blot"], + validationDate: "2020-01-01", + contaminationStatus: "not_detected" + } + ], + assays: [ + { name: "Human pERK blot", materialName: "Legacy anti-pERK", organism: "human", target: "pERK" } + ] + }); + + assert.equal(review.decision, "REVIEW"); + assert.ok(review.findings.some((item) => item.code === "MISSING_OR_INVALID_RRID")); + assert.ok(review.findings.some((item) => item.code === "STALE_VALIDATION_DATE")); + assert.ok(review.findings.some((item) => item.code === "ASSAY_ORGANISM_MISMATCH")); +}); + +test("holds contaminated cell-derived materials", () => { + const review = analyzePacket({ + id: "contaminated", + title: "Contaminated cell stock", + reviewDate: "2026-08-15", + materials: [ + { + name: "Cell stock", + type: "cell_line", + rrid: "RRID:CVCL_SYN9", + lot: "CELL-01", + species: "human", + validationEvidence: ["STR profile"], + validationDate: "2026-07-01", + contaminationStatus: "positive", + passageRange: { min: 2, max: 9 }, + reportedPassage: 5 + } + ], + assays: [] + }); + + assert.equal(review.decision, "HOLD"); + assert.ok(review.findings.some((item) => item.code === "CONTAMINATED_CELL_MATERIAL")); +}); + +test("holds antibody target mismatch and impossible passage ranges", () => { + const review = analyzePacket({ + id: "hold", + title: "Mismatched antibody target", + reviewDate: "2026-08-15", + materials: [ + { + name: "Stem cell stock", + type: "primary_cell", + rrid: "RRID:CVCL_SYN8", + lot: "STEM-01", + species: "human", + validationEvidence: ["identity PCR"], + validationDate: "2026-07-01", + contaminationStatus: "negative", + passageRange: { min: 12, max: 4 }, + reportedPassage: 18 + }, + { + name: "Anti-GFAP", + type: "antibody", + rrid: "RRID:AB_100002", + lot: "AB-02", + species: "human", + target: "GFAP", + assayTargets: ["GFAP"], + validationEvidence: ["knockdown panel"], + validationDate: "2026-07-01", + contaminationStatus: "not_detected" + } + ], + assays: [ + { name: "MAP2 stain", materialName: "Anti-GFAP", organism: "human", target: "MAP2" } + ] + }); + + assert.equal(review.decision, "HOLD"); + assert.ok(review.findings.some((item) => item.code === "ANTIBODY_TARGET_MISMATCH")); + assert.ok(review.findings.some((item) => item.code === "IMPOSSIBLE_PASSAGE_RANGE")); +}); + +test("summarizes packet decisions deterministically", () => { + const report = analyzePackets([ + { + id: "release", + title: "Release", + materials: [ + { + name: "Anti-A", + type: "antibody", + rrid: "RRID:AB_1", + lot: "L1", + species: "human", + target: "A", + assayTargets: ["A"], + validationEvidence: ["specificity"], + validationDate: "2026-07-01", + contaminationStatus: "not_detected" + } + ], + assays: [{ name: "A", materialName: "Anti-A", organism: "human", target: "A" }] + }, + { + id: "hold", + title: "Hold", + materials: [ + { + name: "Line", + type: "cell_line", + rrid: "RRID:CVCL_1", + lot: "L2", + species: "human", + validationEvidence: ["STR"], + validationDate: "2026-07-01", + contaminationStatus: "detected" + } + ], + assays: [] + } + ]); + + assert.equal(report.packetCount, 2); + assert.equal(report.decisionCounts.RELEASE, 1); + assert.equal(report.decisionCounts.HOLD, 1); +});