diff --git a/peer-review-assignment-governance/README.md b/peer-review-assignment-governance/README.md new file mode 100644 index 0000000..0bac20e --- /dev/null +++ b/peer-review-assignment-governance/README.md @@ -0,0 +1,41 @@ +# Peer Review Assignment Governance + +Self-contained contribution for SCIBASE issue #15, focused on conflict-of-interest checks and reviewer assignment governance for the Community & User Reputation System. + +The module helps review chairs answer: + +- Which reviewers are eligible for a public, anonymous, or double-blind assignment? +- Which candidates are blocked by institution, collaboration, coauthor, financial, or disclosure conflicts? +- Does the selected slate cover the project methods and scientific topics? +- Which trust, workload, and reputation signals drove the assignment decision? + +It is deterministic, dependency-free, credential-free, and uses synthetic sample data only. + +## Run + +```bash +cd peer-review-assignment-governance +npm run check +npm test +npm run demo +``` + +Demo recording: `docs/review-assignment-demo.mp4` + +## What It Includes + +- `src/review-assignment-governance.js` - core COI detection, trust scoring, workload balancing, reviewer slate construction, chair checklist, and audit digest logic. +- `sample-data.json` - synthetic project, reviewer profiles, workload, funder, institution, and disclosure records. +- `test.js` - Node assertion tests for overlap scoring, hard COI blocking, conditional reviewers, slate coverage, digest changes, and validation errors. +- `demo.js` - CLI reviewer-chair demo that prints the selected slate, blocked reviewers, checklist, coverage, and audit digest. +- `docs/requirement-map.md` - direct mapping to issue #15 requirements. + +## Design Notes + +This is intentionally not another broad reputation ledger. It focuses on the operational point where reputation can fail if conflicts are not handled before peer-review invitations: + +- Hard conflicts are blocked before ranking. +- Medium-risk disclosures require chair review instead of being hidden inside a score. +- Reviewer identities are masked with blind labels in the selected slate. +- Topic and method coverage are checked after assignment so review chairs can spot gaps. +- The audit digest lets future backend work persist and compare assignment packets. diff --git a/peer-review-assignment-governance/demo.js b/peer-review-assignment-governance/demo.js new file mode 100644 index 0000000..efbb93d --- /dev/null +++ b/peer-review-assignment-governance/demo.js @@ -0,0 +1,42 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzeReviewAssignmentGovernance } = require("./src/review-assignment-governance"); + +const samplePath = path.join(__dirname, "sample-data.json"); +const bundle = JSON.parse(fs.readFileSync(samplePath, "utf8")); +const report = analyzeReviewAssignmentGovernance(bundle); + +console.log("Peer Review Assignment Governance Demo"); +console.log("======================================"); +console.log(`Project: ${report.projectId}`); +console.log(`Review mode: ${report.reviewMode}`); +console.log(`Decision: ${report.assignmentDecision}`); +console.log(""); + +console.log("Selected reviewer slate"); +for (const reviewer of report.selectedReviewers) { + const conflicts = reviewer.conflictReasons.length + ? ` | disclosures: ${reviewer.conflictReasons.map((reason) => reason.code).join(", ")}` + : ""; + console.log(`- ${reviewer.blindLabel}: ${reviewer.status} score ${reviewer.score}${conflicts}`); + console.log(` coverage: ${reviewer.coverage.topics.concat(reviewer.coverage.methods).join("; ") || "general"}`); +} +console.log(""); + +console.log("Blocked reviewers"); +for (const reviewer of report.blockedReviewers) { + console.log(`- ${reviewer.reviewerId}: ${reviewer.conflictReasons.map((reason) => reason.code).join(", ")}`); +} +console.log(""); + +console.log("Chair checklist"); +for (const item of report.chairChecklist) { + console.log(`- [${item.status}] ${item.id}: ${item.message}`); +} +console.log(""); + +console.log(`Covered priority topics: ${report.coverage.covered.join(", ")}`); +console.log(`Missing priority topics: ${report.coverage.missing.join(", ") || "none"}`); +console.log(`Audit digest: ${report.auditDigest}`); diff --git a/peer-review-assignment-governance/docs/requirement-map.md b/peer-review-assignment-governance/docs/requirement-map.md new file mode 100644 index 0000000..beaa325 --- /dev/null +++ b/peer-review-assignment-governance/docs/requirement-map.md @@ -0,0 +1,35 @@ +# Requirement Map + +This module targets SCIBASE issue #15, Community & User Reputation System. + +## Peer Reviews and Comments + +- Models public, anonymous, and double-blind review safety through `project.reviewMode`. +- Blocks reviewers with self, institution, recent collaboration, coauthor, or financial conflicts. +- Produces a chair checklist so review coordinators can act on blind-review and coverage risks before invitations are sent. + +## Contributor Credits + +- Uses reviewer profile history, completed reviews, turnaround, reproducibility score, endorsements, dispute rate, and overdue reviews as transparent contribution signals. +- Keeps reviewer identity masked in the selected slate with stable blind labels while preserving auditability for the chair. + +## Reputation Scoring + +- Computes deterministic score components for expertise, trust, workload, and conflict penalties. +- Separates eligible, conditional, and blocked reviewers so reputation signals do not hide hard conflicts. +- Includes alternates and missing coverage so assignment decisions can be reviewed without private credentials or live user data. + +## Abuse and Quality Guardrails + +- Hard-blocks high-severity COI signals before ranking. +- Downgrades shared-funder, workload, dispute, and calibration-drift risks to conditional review instead of silently selecting them. +- Emits a SHA-256 audit digest over the assignment packet to make future reviewer slates comparable. + +## Local Verification + +```bash +cd peer-review-assignment-governance +npm run check +npm test +npm run demo +``` diff --git a/peer-review-assignment-governance/docs/review-assignment-demo.mp4 b/peer-review-assignment-governance/docs/review-assignment-demo.mp4 new file mode 100644 index 0000000..4bf672c Binary files /dev/null and b/peer-review-assignment-governance/docs/review-assignment-demo.mp4 differ diff --git a/peer-review-assignment-governance/package.json b/peer-review-assignment-governance/package.json new file mode 100644 index 0000000..151c565 --- /dev/null +++ b/peer-review-assignment-governance/package.json @@ -0,0 +1,13 @@ +{ + "name": "peer-review-assignment-governance", + "version": "1.0.0", + "description": "Deterministic peer-review conflict-of-interest and assignment governance module for SCIBASE issue 15.", + "main": "src/review-assignment-governance.js", + "scripts": { + "check": "node --check src/review-assignment-governance.js && node --check demo.js && node --check test.js", + "demo": "node demo.js", + "test": "node test.js" + }, + "license": "MIT", + "private": true +} diff --git a/peer-review-assignment-governance/sample-data.json b/peer-review-assignment-governance/sample-data.json new file mode 100644 index 0000000..426de76 --- /dev/null +++ b/peer-review-assignment-governance/sample-data.json @@ -0,0 +1,283 @@ +{ + "project": { + "id": "proj-alz-crispr-042", + "title": "CRISPR microglia biomarker atlas", + "discipline": "biomedicine", + "reviewMode": "double-blind", + "requiredReviewers": 3, + "priorityTopics": [ + "CRISPR", + "microglia", + "Alzheimer disease", + "single-cell sequencing", + "reproducibility" + ], + "methods": [ + "single-cell RNA sequencing", + "CRISPR screen", + "Bayesian mixed model" + ], + "artifactScopes": [ + "manuscript", + "dataset", + "analysis notebook" + ], + "authorIds": [ + "author-nb-01", + "author-hu-02" + ], + "authorInstitutions": [ + "Northbridge Lab", + "Helix University" + ], + "funders": [ + "Open Neuro Fund" + ] + }, + "reviewers": [ + { + "id": "reviewer-ortiz", + "displayName": "Dr. Lena Ortiz", + "institution": "Cedar Institute", + "discipline": "biomedicine", + "topics": [ + "CRISPR", + "microglia", + "neuroinflammation", + "Alzheimer disease" + ], + "methods": [ + "single-cell RNA sequencing", + "CRISPR screen" + ], + "recentCollaboratorIds": [], + "recentCoauthorIds": [], + "funders": [ + "Open Tools Grant" + ], + "activeAssignments": 1, + "profile": { + "completedReviews": 42, + "medianTurnaroundDays": 5, + "endorsementScore": 82, + "reproducibilityScore": 91, + "disputeRate": 0.03, + "overdueReviews": 0, + "blindReviewTraining": true, + "consensusDrift": 0.04 + } + }, + { + "id": "reviewer-shah", + "displayName": "Prof. Mina Shah", + "institution": "Helix University", + "discipline": "biomedicine", + "topics": [ + "microglia", + "Alzheimer disease", + "disease biomarkers" + ], + "methods": [ + "immunostaining", + "single-cell RNA sequencing" + ], + "recentCollaboratorIds": [], + "recentCoauthorIds": [], + "funders": [ + "Open Neuro Fund" + ], + "activeAssignments": 0, + "profile": { + "completedReviews": 55, + "medianTurnaroundDays": 6, + "endorsementScore": 89, + "reproducibilityScore": 84, + "disputeRate": 0.02, + "overdueReviews": 0, + "blindReviewTraining": true, + "consensusDrift": 0.02 + } + }, + { + "id": "reviewer-nolan", + "displayName": "Kai Nolan", + "institution": "Westport Bioinformatics", + "discipline": "computational biology", + "topics": [ + "single-cell sequencing", + "knowledge graph", + "reproducibility", + "notebook audit" + ], + "methods": [ + "Bayesian mixed model", + "workflow reproducibility", + "notebook execution" + ], + "recentCollaboratorIds": [], + "recentCoauthorIds": [], + "funders": [ + "Reproducible Science Trust" + ], + "activeAssignments": 1, + "profile": { + "completedReviews": 31, + "medianTurnaroundDays": 6, + "endorsementScore": 80, + "reproducibilityScore": 96, + "disputeRate": 0.05, + "overdueReviews": 0, + "blindReviewTraining": true, + "consensusDrift": 0.06 + } + }, + { + "id": "reviewer-patel", + "displayName": "Arun Patel", + "institution": "Southlake Genomics", + "discipline": "biomedicine", + "topics": [ + "CRISPR", + "microglia", + "cell atlas" + ], + "methods": [ + "CRISPR screen", + "flow cytometry" + ], + "recentCollaboratorIds": [ + "author-nb-01" + ], + "recentCoauthorIds": [], + "funders": [], + "activeAssignments": 0, + "profile": { + "completedReviews": 28, + "medianTurnaroundDays": 4, + "endorsementScore": 80, + "reproducibilityScore": 78, + "disputeRate": 0.04, + "overdueReviews": 0, + "blindReviewTraining": false, + "consensusDrift": 0.05 + } + }, + { + "id": "reviewer-chen", + "displayName": "Eli Chen", + "institution": "Open Methods Lab", + "discipline": "statistics", + "topics": [ + "Bayesian modeling", + "reproducibility", + "p-value audit", + "open science" + ], + "methods": [ + "Bayesian mixed model", + "statistical review", + "data validation" + ], + "recentCollaboratorIds": [], + "recentCoauthorIds": [], + "funders": [ + "Methods Commons" + ], + "activeAssignments": 1, + "profile": { + "completedReviews": 38, + "medianTurnaroundDays": 6, + "endorsementScore": 79, + "reproducibilityScore": 88, + "disputeRate": 0.04, + "overdueReviews": 0, + "blindReviewTraining": true, + "consensusDrift": 0.03 + } + }, + { + "id": "reviewer-okafor", + "displayName": "Nora Okafor", + "institution": "Metro Data Stewardship Center", + "discipline": "data stewardship", + "topics": [ + "dataset curation", + "FAIR metadata", + "reproducibility", + "consent review" + ], + "methods": [ + "metadata audit", + "notebook execution", + "access review" + ], + "recentCollaboratorIds": [], + "recentCoauthorIds": [], + "funders": [], + "activeAssignments": 5, + "profile": { + "completedReviews": 44, + "medianTurnaroundDays": 11, + "endorsementScore": 73, + "reproducibilityScore": 93, + "disputeRate": 0.08, + "overdueReviews": 2, + "blindReviewTraining": true, + "consensusDrift": 0.13 + } + }, + { + "id": "reviewer-meyer", + "displayName": "Sofia Meyer", + "institution": "Riverside Neurotech", + "discipline": "neuroscience", + "topics": [ + "Alzheimer disease", + "biomarkers", + "research ethics", + "protocol reproducibility" + ], + "methods": [ + "protocol review", + "ethics checklist", + "replication planning" + ], + "recentCollaboratorIds": [], + "recentCoauthorIds": [], + "funders": [ + "Open Neuro Fund" + ], + "activeAssignments": 0, + "profile": { + "completedReviews": 24, + "medianTurnaroundDays": 8, + "endorsementScore": 71, + "reproducibilityScore": 82, + "disputeRate": 0.06, + "overdueReviews": 0, + "blindReviewTraining": true, + "consensusDrift": 0.07 + } + } + ], + "disclosures": [ + { + "reviewerId": "reviewer-shah", + "type": "current-institution", + "severity": "high", + "summary": "Reviewer is at an author institution." + }, + { + "reviewerId": "reviewer-patel", + "type": "recent-collaboration", + "severity": "high", + "summary": "Reviewer collaborated with the lead author in the last 24 months." + }, + { + "reviewerId": "reviewer-meyer", + "type": "shared-funder", + "severity": "medium", + "summary": "Reviewer has a current award from one project funder." + } + ] +} diff --git a/peer-review-assignment-governance/src/review-assignment-governance.js b/peer-review-assignment-governance/src/review-assignment-governance.js new file mode 100644 index 0000000..4ea56c5 --- /dev/null +++ b/peer-review-assignment-governance/src/review-assignment-governance.js @@ -0,0 +1,418 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const HARD_CONFLICT_TYPES = new Set([ + "author-self", + "current-institution", + "recent-collaboration", + "recent-coauthor", + "financial-interest", +]); + +const SEVERITY_WEIGHT = { + low: 5, + medium: 18, + high: 100, +}; + +function ensureBundle(bundle) { + for (const field of ["project", "reviewers"]) { + if (!bundle || !bundle[field]) { + throw new Error(`missing required bundle field: ${field}`); + } + } + if (!Array.isArray(bundle.reviewers) || bundle.reviewers.length === 0) { + throw new Error("reviewers must include at least one candidate"); + } +} + +function list(value) { + return Array.isArray(value) ? value : []; +} + +function round(value) { + return Math.round(value * 100) / 100; +} + +function tokenize(value) { + return list(value) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, " ") + .split(/\s+/) + .filter((token) => token.length > 2); +} + +function overlapScore(left, right) { + const a = new Set(tokenize(left)); + const b = new Set(tokenize(right)); + if (a.size === 0 || b.size === 0) { + return 0; + } + + let shared = 0; + for (const token of a) { + if (b.has(token)) { + shared += 1; + } + } + return round(shared / Math.max(a.size, b.size)); +} + +function disclosureReasons(reviewer, disclosures) { + return list(disclosures) + .filter((item) => item.reviewerId === reviewer.id) + .map((item) => ({ + code: item.type, + severity: item.severity || "medium", + message: item.summary || `${item.type} disclosure`, + source: "self-disclosure", + })); +} + +function detectConflicts(project, reviewer, disclosures = []) { + const reasons = disclosureReasons(reviewer, disclosures); + const authorIds = new Set(list(project.authorIds)); + const authorInstitutions = new Set(list(project.authorInstitutions).map((item) => item.toLowerCase())); + const reviewerInstitution = String(reviewer.institution || "").toLowerCase(); + + if (authorIds.has(reviewer.id)) { + reasons.push({ + code: "author-self", + severity: "high", + message: "Reviewer is also listed as a project author.", + source: "platform-record", + }); + } + + if (reviewerInstitution && authorInstitutions.has(reviewerInstitution)) { + reasons.push({ + code: "current-institution", + severity: "high", + message: "Reviewer shares an institution with a project author.", + source: "platform-record", + }); + } + + const collaborators = new Set(list(reviewer.recentCollaboratorIds)); + const coauthors = new Set(list(reviewer.recentCoauthorIds)); + for (const authorId of authorIds) { + if (collaborators.has(authorId)) { + reasons.push({ + code: "recent-collaboration", + severity: "high", + message: "Reviewer has a recent collaboration with a project author.", + source: "platform-record", + }); + } + if (coauthors.has(authorId)) { + reasons.push({ + code: "recent-coauthor", + severity: "high", + message: "Reviewer has a recent coauthorship with a project author.", + source: "platform-record", + }); + } + } + + const projectFunders = new Set(list(project.funders).map((item) => item.toLowerCase())); + const sharedFunders = list(reviewer.funders).filter((item) => projectFunders.has(String(item).toLowerCase())); + if (sharedFunders.length > 0) { + reasons.push({ + code: "shared-funder", + severity: "medium", + message: `Reviewer shares project funder: ${sharedFunders.join(", ")}.`, + source: "platform-record", + }); + } + + const dedupedReasons = []; + const seenCodes = new Set(); + for (const reason of reasons) { + if (!seenCodes.has(reason.code)) { + dedupedReasons.push(reason); + seenCodes.add(reason.code); + } + } + + const hardBlocked = dedupedReasons.some((reason) => { + return reason.severity === "high" && HARD_CONFLICT_TYPES.has(reason.code); + }); + + return { + hardBlocked, + reasons: dedupedReasons, + penalty: dedupedReasons.reduce((total, reason) => total + (SEVERITY_WEIGHT[reason.severity] || 10), 0), + }; +} + +function trustScore(profile = {}) { + const completed = Math.min(30, Number(profile.completedReviews || 0) * 0.7); + const endorsement = Math.min(20, Number(profile.endorsementScore || 0) * 0.2); + const reproducibility = Math.min(25, Number(profile.reproducibilityScore || 0) * 0.25); + const turnaround = Math.max(0, 15 - Number(profile.medianTurnaroundDays || 14)); + const training = profile.blindReviewTraining ? 7 : 0; + const disputePenalty = Math.min(20, Number(profile.disputeRate || 0) * 100); + const overduePenalty = Math.min(12, Number(profile.overdueReviews || 0) * 4); + const driftPenalty = Math.min(12, Number(profile.consensusDrift || 0) * 60); + + return Math.max(0, round(completed + endorsement + reproducibility + turnaround + training - disputePenalty - overduePenalty - driftPenalty)); +} + +function expertiseScore(project, reviewer) { + const discipline = String(project.discipline || "").toLowerCase() === String(reviewer.discipline || "").toLowerCase() ? 20 : 8; + const topic = overlapScore(project.priorityTopics, reviewer.topics) * 42; + const method = overlapScore(project.methods, reviewer.methods) * 30; + const artifact = overlapScore(project.artifactScopes, reviewer.methods.concat(reviewer.topics || [])) * 8; + return round(Math.min(100, discipline + topic + method + artifact)); +} + +function workloadScore(reviewer) { + const active = Number(reviewer.activeAssignments || 0); + const overdue = Number((reviewer.profile && reviewer.profile.overdueReviews) || 0); + return Math.max(0, 100 - active * 12 - overdue * 10); +} + +function evaluateReviewer(project, reviewer, disclosures = []) { + const conflicts = detectConflicts(project, reviewer, disclosures); + const expertise = expertiseScore(project, reviewer); + const trust = trustScore(reviewer.profile || {}); + const workload = workloadScore(reviewer); + const qualityRisk = Number((reviewer.profile && reviewer.profile.disputeRate) || 0) >= 0.15; + + let rawScore = expertise * 0.45 + trust * 0.35 + workload * 0.2; + rawScore -= conflicts.hardBlocked ? 1000 : conflicts.penalty; + rawScore = round(Math.max(0, Math.min(100, rawScore))); + + let status = "eligible"; + if (conflicts.hardBlocked) { + status = "blocked"; + } else if (qualityRisk || rawScore < 55 || conflicts.reasons.some((reason) => reason.severity === "medium")) { + status = "conditional"; + } + + return { + reviewerId: reviewer.id, + blindLabel: "", + status, + score: rawScore, + components: { + expertise, + trust, + workload, + conflictPenalty: conflicts.hardBlocked ? "hard-block" : conflicts.penalty, + }, + conflictReasons: conflicts.reasons, + coverage: { + topics: list(reviewer.topics).filter((topic) => overlapScore([topic], project.priorityTopics) > 0), + methods: list(reviewer.methods).filter((method) => overlapScore([method], project.methods) > 0), + }, + }; +} + +function coversToken(reviewer, token) { + const haystack = tokenize(list(reviewer.topics).concat(list(reviewer.methods))); + const needles = tokenize([token]); + return needles.some((needle) => haystack.includes(needle)); +} + +function selectedCoverage(project, reviewersById, selectedIds) { + const covered = []; + const missing = []; + + for (const topic of list(project.priorityTopics)) { + const isCovered = selectedIds.some((id) => coversToken(reviewersById.get(id), topic)); + if (isCovered) { + covered.push(topic); + } else { + missing.push(topic); + } + } + + return { covered, missing }; +} + +function buildAssignmentSlate(project, reviewers, disclosures = []) { + const reviewersById = new Map(reviewers.map((reviewer) => [reviewer.id, reviewer])); + const scorecards = reviewers + .map((reviewer) => evaluateReviewer(project, reviewer, disclosures)) + .sort((a, b) => b.score - a.score || a.reviewerId.localeCompare(b.reviewerId)); + + const required = Number(project.requiredReviewers || 3); + const selected = []; + const selectedInstitutions = new Set(); + + for (const card of scorecards.filter((item) => item.status === "eligible")) { + const reviewer = reviewersById.get(card.reviewerId); + const institution = String(reviewer.institution || "").toLowerCase(); + const wouldDuplicateInstitution = institution && selectedInstitutions.has(institution); + const remainingDistinct = scorecards.some((candidate) => { + const candidateReviewer = reviewersById.get(candidate.reviewerId); + return ( + candidate.status === "eligible" && + !selected.includes(candidate.reviewerId) && + String(candidateReviewer.institution || "").toLowerCase() !== institution + ); + }); + + if (wouldDuplicateInstitution && remainingDistinct) { + continue; + } + + selected.push(card.reviewerId); + if (institution) { + selectedInstitutions.add(institution); + } + if (selected.length === required) { + break; + } + } + + if (selected.length < required) { + for (const card of scorecards.filter((item) => item.status === "conditional")) { + if (!selected.includes(card.reviewerId)) { + selected.push(card.reviewerId); + } + if (selected.length === required) { + break; + } + } + } + + const selectedSet = new Set(selected); + const alternates = scorecards + .filter((card) => card.status !== "blocked" && !selectedSet.has(card.reviewerId)) + .slice(0, 3) + .map((card) => card.reviewerId); + + const coverage = selectedCoverage(project, reviewersById, selected); + const selectedCards = selected.map((id, index) => ({ + ...scorecards.find((card) => card.reviewerId === id), + blindLabel: `Reviewer ${String.fromCharCode(65 + index)}`, + })); + const alternateCards = alternates.map((id, index) => ({ + ...scorecards.find((card) => card.reviewerId === id), + blindLabel: `Alternate ${index + 1}`, + })); + const blockedCards = scorecards.filter((card) => card.status === "blocked"); + + let decision = "ready-for-invitation"; + if (selected.length < required || coverage.missing.length > 1) { + decision = "needs-review-chair"; + } else if (selectedCards.some((card) => card.status === "conditional") || coverage.missing.length === 1) { + decision = "chair-review-recommended"; + } + + return { + decision, + requiredReviewers: required, + selectedReviewers: selectedCards, + alternates: alternateCards, + blockedReviewers: blockedCards, + coverage, + scorecards, + }; +} + +function chairChecklist(project, slate) { + const items = []; + + if (project.reviewMode && project.reviewMode.includes("blind")) { + items.push({ + id: "blind-safety", + status: slate.blockedReviewers.length > 0 ? "attention" : "pass", + message: "Confirm blocked reviewers stay hidden from author-facing assignment logs.", + }); + } + + if (slate.coverage.missing.length > 0) { + items.push({ + id: "coverage-gap", + status: "attention", + message: `Add specialist coverage for: ${slate.coverage.missing.join(", ")}.`, + }); + } + + const conditional = slate.selectedReviewers.filter((card) => card.status === "conditional"); + if (conditional.length > 0) { + items.push({ + id: "conditional-reviewer", + status: "attention", + message: `Chair approval required for ${conditional.map((card) => card.blindLabel).join(", ")}.`, + }); + } + + items.push({ + id: "credit-trace", + status: "pass", + message: "Assignment packet records reviewer trust signals without exposing author identities.", + }); + + return items; +} + +function auditDigest(value) { + const normalized = stableStringify(value); + return crypto.createHash("sha256").update(normalized).digest("hex"); +} + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function analyzeReviewAssignmentGovernance(bundle) { + ensureBundle(bundle); + const project = bundle.project; + const slate = buildAssignmentSlate(project, bundle.reviewers, bundle.disclosures || []); + const audit = { + projectId: project.id, + reviewMode: project.reviewMode || "public", + decision: slate.decision, + selected: slate.selectedReviewers.map((card) => ({ + reviewerId: card.reviewerId, + blindLabel: card.blindLabel, + score: card.score, + status: card.status, + })), + blocked: slate.blockedReviewers.map((card) => ({ + reviewerId: card.reviewerId, + reasons: card.conflictReasons.map((reason) => reason.code), + })), + missingCoverage: slate.coverage.missing, + }; + + return { + module: "peer-review-assignment-governance", + projectId: project.id, + reviewMode: project.reviewMode || "public", + assignmentDecision: slate.decision, + selectedReviewers: slate.selectedReviewers, + alternates: slate.alternates, + blockedReviewers: slate.blockedReviewers, + coverage: slate.coverage, + chairChecklist: chairChecklist(project, slate), + scorecards: slate.scorecards, + auditDigest: auditDigest(audit), + }; +} + +module.exports = { + analyzeReviewAssignmentGovernance, + buildAssignmentSlate, + detectConflicts, + evaluateReviewer, + expertiseScore, + overlapScore, + trustScore, + workloadScore, +}; diff --git a/peer-review-assignment-governance/test.js b/peer-review-assignment-governance/test.js new file mode 100644 index 0000000..e34751c --- /dev/null +++ b/peer-review-assignment-governance/test.js @@ -0,0 +1,81 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + analyzeReviewAssignmentGovernance, + detectConflicts, + evaluateReviewer, + overlapScore, + trustScore, +} = require("./src/review-assignment-governance"); + +const samplePath = path.join(__dirname, "sample-data.json"); +const sample = JSON.parse(fs.readFileSync(samplePath, "utf8")); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +{ + const score = overlapScore(["CRISPR", "microglia", "single-cell sequencing"], ["single cell RNA sequencing", "microglia atlas"]); + assert.ok(score >= 0.25, `expected overlap for related scientific terms, got ${score}`); +} + +{ + const reviewer = sample.reviewers.find((item) => item.id === "reviewer-ortiz"); + const trust = trustScore(reviewer.profile); + assert.ok(trust > 65, `expected strong reviewer trust score, got ${trust}`); +} + +{ + const reviewer = sample.reviewers.find((item) => item.id === "reviewer-shah"); + const conflicts = detectConflicts(sample.project, reviewer, sample.disclosures); + assert.equal(conflicts.hardBlocked, true); + assert.ok(conflicts.reasons.some((reason) => reason.code === "current-institution")); +} + +{ + const reviewer = sample.reviewers.find((item) => item.id === "reviewer-meyer"); + const card = evaluateReviewer(sample.project, reviewer, sample.disclosures); + assert.equal(card.status, "conditional"); + assert.ok(card.conflictReasons.some((reason) => reason.code === "shared-funder")); +} + +{ + const report = analyzeReviewAssignmentGovernance(sample); + assert.equal(report.assignmentDecision, "ready-for-invitation"); + assert.equal(report.selectedReviewers.length, 3); + assert.deepEqual( + report.selectedReviewers.map((reviewer) => reviewer.blindLabel), + ["Reviewer A", "Reviewer B", "Reviewer C"] + ); + assert.ok(report.selectedReviewers.some((reviewer) => reviewer.reviewerId === "reviewer-ortiz")); + assert.ok(report.blockedReviewers.some((reviewer) => reviewer.reviewerId === "reviewer-shah")); + assert.ok(report.blockedReviewers.some((reviewer) => reviewer.reviewerId === "reviewer-patel")); + assert.equal(report.coverage.missing.length, 0); + assert.match(report.auditDigest, /^[a-f0-9]{64}$/); +} + +{ + const changed = clone(sample); + changed.disclosures.push({ + reviewerId: "reviewer-ortiz", + type: "financial-interest", + severity: "high", + summary: "Reviewer has a direct financial interest in a competing assay." + }); + const original = analyzeReviewAssignmentGovernance(sample); + const updated = analyzeReviewAssignmentGovernance(changed); + assert.notEqual(updated.auditDigest, original.auditDigest); + assert.ok(updated.blockedReviewers.some((reviewer) => reviewer.reviewerId === "reviewer-ortiz")); +} + +{ + const missing = clone(sample); + delete missing.reviewers; + assert.throws(() => analyzeReviewAssignmentGovernance(missing), /missing required bundle field: reviewers/); +} + +console.log("peer-review-assignment-governance tests passed");