From a74650b4e4524ef96937b8617ed3abf625c153f1 Mon Sep 17 00:00:00 2001 From: Telemark Codex Agent Date: Sat, 15 Aug 2026 22:46:38 +0200 Subject: [PATCH] Add research materials authentication assistant --- .../README.md | 54 +++ .../data/sample_material_packets.json | 170 +++++++++ .../package.json | 13 + .../reports/demo.mp4 | Bin 0 -> 51455 bytes .../reports/reviewer-packet.md | 56 +++ .../reports/summary.json | 156 ++++++++ .../reports/summary.svg | 14 + .../scripts/demo.js | 76 ++++ .../scripts/render-demo-video.js | 60 ++++ .../src/index.js | 333 ++++++++++++++++++ ...materials-authentication-assistant.test.js | 182 ++++++++++ 11 files changed, 1114 insertions(+) create mode 100644 research-materials-authentication-assistant/README.md create mode 100644 research-materials-authentication-assistant/data/sample_material_packets.json create mode 100644 research-materials-authentication-assistant/package.json create mode 100644 research-materials-authentication-assistant/reports/demo.mp4 create mode 100644 research-materials-authentication-assistant/reports/reviewer-packet.md create mode 100644 research-materials-authentication-assistant/reports/summary.json create mode 100644 research-materials-authentication-assistant/reports/summary.svg create mode 100644 research-materials-authentication-assistant/scripts/demo.js create mode 100644 research-materials-authentication-assistant/scripts/render-demo-video.js create mode 100644 research-materials-authentication-assistant/src/index.js create mode 100644 research-materials-authentication-assistant/test/research-materials-authentication-assistant.test.js 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 0000000000000000000000000000000000000000..11770451a47611ec30ca40dc6b5768a2a5bba126 GIT binary patch literal 51455 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 literal 0 HcmV?d00001 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); +});