diff --git a/reviewer-expertise-credential-guard/README.md b/reviewer-expertise-credential-guard/README.md new file mode 100644 index 00000000..79c2d09a --- /dev/null +++ b/reviewer-expertise-credential-guard/README.md @@ -0,0 +1,18 @@ +# Reviewer Expertise Credential Guard + +This module adds a focused community reputation guard for trusted reviewer expertise credentials. + +It evaluates whether a reviewer should receive weighted assignment credit or trusted-reviewer badge eligibility by checking declared expertise, evidence-backed domains, method credentials, evidence freshness, review history, conflicts of interest, and anonymous-review redaction. + +## Run + +```sh +node reviewer-expertise-credential-guard/test.js +node reviewer-expertise-credential-guard/demo.js +``` + +The demo writes JSON and Markdown reviewer artifacts to `reviewer-expertise-credential-guard/reports/`. + +## Review Surface + +The implementation is dependency-free, uses synthetic data only, and does not call external APIs or read credentials. diff --git a/reviewer-expertise-credential-guard/acceptance-notes.md b/reviewer-expertise-credential-guard/acceptance-notes.md new file mode 100644 index 00000000..aab68e5a --- /dev/null +++ b/reviewer-expertise-credential-guard/acceptance-notes.md @@ -0,0 +1,19 @@ +# Acceptance Notes + +## Validation + +- `node reviewer-expertise-credential-guard/test.js` +- `node reviewer-expertise-credential-guard/demo.js` +- `node --check reviewer-expertise-credential-guard/index.js` +- `node --check reviewer-expertise-credential-guard/test.js` +- `node --check reviewer-expertise-credential-guard/demo.js` +- `ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_name,width,height -of default=noprint_wrappers=1 reviewer-expertise-credential-guard/demo.mp4` + +## Acceptance Coverage + +- Current domain and method evidence can make a reviewer trusted-reviewer eligible. +- Missing domain evidence lowers review weight and requires steward review. +- Conflicts block weighted assignment credit. +- Stale credentials require refresh before trusted badge elevation. +- Anonymous review mode redacts display name and ORCID from public profiles. +- The output audit digest is deterministic for reviewer replay. diff --git a/reviewer-expertise-credential-guard/demo.js b/reviewer-expertise-credential-guard/demo.js new file mode 100644 index 00000000..d07fefe6 --- /dev/null +++ b/reviewer-expertise-credential-guard/demo.js @@ -0,0 +1,100 @@ +const fs = require("fs"); +const path = require("path"); +const { evaluateReviewerExpertiseCredentials } = require("./index"); + +const outputDir = path.join(__dirname, "reports"); +fs.mkdirSync(outputDir, { recursive: true }); + +const packet = { + now: "2026-06-01T12:00:00Z", + maxEvidenceAgeDays: 730, + minimumAcceptedReviews: 3, + reviewers: [ + { + id: "reviewer-ada", + displayName: "Ada Reviewer", + orcid: "0000-0002-1825-0097", + institution: "open-science-lab", + declaredDomains: ["proteomics", "machine-learning"], + declaredMethods: ["bayesian-modeling", "mass-spectrometry"], + acceptedReviews: 16, + evidence: [ + { type: "domain", domains: ["proteomics"], issuedAt: "2026-01-15T00:00:00Z", sourceId: "orcid-work-proteomics" }, + { type: "domain", domains: ["machine-learning"], issuedAt: "2026-02-10T00:00:00Z", sourceId: "grant-ml" }, + { type: "method", methods: ["bayesian-modeling"], issuedAt: "2026-03-01T00:00:00Z", sourceId: "review-method" }, + ], + conflicts: [], + }, + { + id: "reviewer-byron", + displayName: "Byron Reviewer", + institution: "northbridge-university", + declaredDomains: ["materials-science"], + declaredMethods: ["density-functional-theory"], + acceptedReviews: 2, + evidence: [{ type: "domain", domains: ["materials-science"], issuedAt: "2023-01-10T00:00:00Z", sourceId: "publication-old" }], + conflicts: [{ authorId: "author-lin", type: "recent-coauthor" }], + }, + ], + assignments: [ + { + id: "assign-protein", + manuscriptId: "ms-protein-forecast", + projectId: "project-protein", + reviewerId: "reviewer-ada", + requiredDomains: ["proteomics", "machine-learning"], + requiredMethods: ["bayesian-modeling"], + anonymousMode: true, + authorIds: ["author-lin"], + institutions: ["northbridge-university"], + }, + { + id: "assign-materials", + manuscriptId: "ms-materials-benchmark", + projectId: "project-materials", + reviewerId: "reviewer-byron", + requiredDomains: ["materials-science"], + requiredMethods: ["electron-microscopy"], + anonymousMode: false, + authorIds: ["author-lin"], + institutions: ["northbridge-university"], + }, + ], +}; + +const report = evaluateReviewerExpertiseCredentials(packet); +const jsonPath = path.join(outputDir, "reviewer-expertise-credential-report.json"); +const markdownPath = path.join(outputDir, "reviewer-expertise-credential-report.md"); + +fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2)); +fs.writeFileSync( + markdownPath, + [ + "# Reviewer Expertise Credential Guard Demo", + "", + `Decision: ${report.decision}`, + `Audit digest: ${report.auditDigest}`, + "", + "## Assignment Decisions", + "", + ...report.assignments.map( + (assignment) => + `- ${assignment.assignmentId}: ${assignment.decision}; badge ${assignment.badge}; weight ${assignment.reviewWeight}`, + ), + "", + "## Findings", + "", + ...report.assignments.flatMap((assignment) => + assignment.findings.map((finding) => `- ${assignment.assignmentId}: ${finding.severity} ${finding.code} - ${finding.message}`), + ), + "", + "## Public Profiles", + "", + ...report.assignments.map((assignment) => `- ${assignment.assignmentId}: ${JSON.stringify(assignment.publicProfile)}`), + "", + ].join("\n"), +); + +console.log(`Wrote ${jsonPath}`); +console.log(`Wrote ${markdownPath}`); +console.log(`${report.decision}: ${report.counts.findings} finding(s), ${report.auditDigest}`); diff --git a/reviewer-expertise-credential-guard/demo.mp4 b/reviewer-expertise-credential-guard/demo.mp4 new file mode 100644 index 00000000..60acefc5 Binary files /dev/null and b/reviewer-expertise-credential-guard/demo.mp4 differ diff --git a/reviewer-expertise-credential-guard/demo.svg b/reviewer-expertise-credential-guard/demo.svg new file mode 100644 index 00000000..75144fb2 --- /dev/null +++ b/reviewer-expertise-credential-guard/demo.svg @@ -0,0 +1,27 @@ + + Reviewer Expertise Credential Guard Demo + A community reputation demo showing trusted-reviewer badge decisions from domain, method, freshness, conflict, and anonymity checks. + + + SCIBASE bounty demo artifact + Reviewer Expertise Credential Guard + Issue #15: community reputation and trusted reviewer badges + + + CREDENTIAL FLOW + 1. Match declared expertise to manuscript needs + 2. Verify domain and method evidence + 3. Detect stale claims and conflicts + 4. Redact anonymous public profile data + 5. Emit badge and review-weight decisions + $ node reviewer-expertise-credential-guard/test.js + tests passed: evidence, conflicts, stale claims, + anonymous redaction, deterministic digest + Reviewer artifacts + reports/reviewer-expertise-credential-report.json + reports/reviewer-expertise-credential-report.md + + + + Committed video demo + focused tests + acceptance notes + diff --git a/reviewer-expertise-credential-guard/index.js b/reviewer-expertise-credential-guard/index.js new file mode 100644 index 00000000..b3022a82 --- /dev/null +++ b/reviewer-expertise-credential-guard/index.js @@ -0,0 +1,290 @@ +const crypto = require("crypto"); + +function asArray(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function normalize(value) { + return String(value || "").trim().toLowerCase(); +} + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).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 digest(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function parseDate(value) { + const time = Date.parse(value || ""); + return Number.isNaN(time) ? 0 : time; +} + +function unique(values) { + return [...new Set(asArray(values).map(normalize).filter(Boolean))]; +} + +function addFinding(findings, severity, code, message, remediation) { + findings.push({ severity, code, message, remediation }); +} + +function daysBetween(start, end) { + if (!start || !end) return Infinity; + return Math.floor(Math.abs(end - start) / 86400000); +} + +function evidenceMatches(evidence, kind, value) { + const target = normalize(value); + return asArray(evidence).filter((item) => { + if (kind && normalize(item.type) !== normalize(kind)) return false; + const domains = unique(item.domains || item.domain); + const methods = unique(item.methods || item.method); + const topics = unique(item.topics || item.topic); + return domains.includes(target) || methods.includes(target) || topics.includes(target); + }); +} + +function hasConflict(reviewer, assignment) { + const projectId = normalize(assignment.projectId || assignment.manuscriptId); + const authorIds = unique(assignment.authorIds); + const institutions = unique(assignment.institutions); + const funders = unique(assignment.funders); + const reviewerInstitution = normalize(reviewer.institution); + + for (const conflict of asArray(reviewer.conflicts)) { + const conflictProject = normalize(conflict.projectId || conflict.manuscriptId); + if (conflictProject && conflictProject === projectId) return conflict.type || "project"; + if (authorIds.includes(normalize(conflict.authorId))) return conflict.type || "author"; + if (funders.includes(normalize(conflict.funder))) return conflict.type || "funder"; + } + + if (reviewerInstitution && institutions.includes(reviewerInstitution)) { + return "shared-institution"; + } + + return ""; +} + +function newestEvidenceAgeDays(matches, now) { + if (matches.length === 0) return Infinity; + const newest = matches + .map((item) => parseDate(item.issuedAt || item.updatedAt || item.createdAt)) + .filter(Boolean) + .sort((a, b) => b - a)[0]; + return daysBetween(newest, now); +} + +function publicReviewerProfile(reviewer, assignment, score, badge) { + const expertiseTags = unique([ + ...asArray(reviewer.declaredDomains), + ...asArray(reviewer.declaredMethods), + ...asArray(reviewer.methods), + ]).slice(0, 8); + + if (assignment.anonymousMode) { + return { + reviewerHash: digest({ reviewerId: reviewer.id, manuscriptId: assignment.manuscriptId }).slice(0, 16), + expertiseTags, + expertiseScore: score, + badge, + identityRedacted: true, + }; + } + + return { + reviewerId: reviewer.id, + displayName: reviewer.displayName || reviewer.id, + orcid: reviewer.orcid, + expertiseTags, + expertiseScore: score, + badge, + identityRedacted: false, + }; +} + +function evaluateAssignment(assignment, reviewersById, context) { + const reviewer = reviewersById[assignment.reviewerId] || {}; + const findings = []; + const requiredDomains = unique(assignment.requiredDomains); + const requiredMethods = unique(assignment.requiredMethods); + const declaredDomains = unique(reviewer.declaredDomains); + const declaredMethods = unique(reviewer.declaredMethods || reviewer.methods); + const evidence = asArray(reviewer.evidence); + const maxEvidenceAgeDays = Number(assignment.maxEvidenceAgeDays || context.maxEvidenceAgeDays || 730); + let score = 100; + + if (!assignment.manuscriptId || !assignment.reviewerId) { + addFinding( + findings, + "blocker", + "ASSIGNMENT_CONTEXT_MISSING", + "Reviewer assignment must include a manuscript id and reviewer id.", + "Attach stable assignment context before weighting a review or awarding expertise reputation.", + ); + score -= 40; + } + + if (!reviewer.id) { + addFinding( + findings, + "blocker", + "REVIEWER_PROFILE_MISSING", + "The assignment references a reviewer profile that is not present in the packet.", + "Load the reviewer profile and expertise evidence before assignment.", + ); + score -= 40; + } + + const conflictType = hasConflict(reviewer, assignment); + if (conflictType) { + addFinding( + findings, + "blocker", + "REVIEWER_CONFLICT_DETECTED", + `Reviewer has a ${conflictType} conflict with this manuscript.`, + "Block weighted review credit until a steward resolves or reassigns the review.", + ); + score -= 45; + } + + for (const domain of requiredDomains) { + const matches = evidenceMatches(evidence, "domain", domain); + if (!declaredDomains.includes(domain)) { + addFinding( + findings, + "warning", + "DOMAIN_NOT_DECLARED", + `Reviewer has not declared the required domain ${domain}.`, + "Ask the reviewer to update profile expertise or route to a better matched reviewer.", + ); + score -= 10; + } + if (matches.length === 0) { + addFinding( + findings, + "warning", + "DOMAIN_EVIDENCE_MISSING", + `Reviewer lacks evidence for required domain ${domain}.`, + "Attach publication, ORCID, grant, or verified review evidence before awarding a trusted reviewer badge.", + ); + score -= 16; + } else if (newestEvidenceAgeDays(matches, context.now) > maxEvidenceAgeDays) { + addFinding( + findings, + "warning", + "DOMAIN_EVIDENCE_STALE", + `Reviewer evidence for ${domain} is older than ${maxEvidenceAgeDays} days.`, + "Refresh the credential evidence before using it for weighted reputation.", + ); + score -= 8; + } + } + + for (const method of requiredMethods) { + const matches = evidenceMatches(evidence, "method", method); + if (!declaredMethods.includes(method) || matches.length === 0) { + addFinding( + findings, + "warning", + "METHOD_EVIDENCE_GAP", + `Reviewer does not have current evidence for required method ${method}.`, + "Lower review weight or request a second reviewer with method-specific credentials.", + ); + score -= 12; + } + } + + const acceptedReviews = Number(reviewer.acceptedReviews || 0); + if (acceptedReviews < Number(context.minimumAcceptedReviews || 3)) { + addFinding( + findings, + "info", + "REVIEW_HISTORY_LIGHT", + "Reviewer has a short accepted-review history for trusted badge elevation.", + "Use steward review before granting a persistent expertise badge.", + ); + score -= 5; + } + + const normalizedScore = Math.max(0, Math.min(100, score)); + const blockers = findings.filter((finding) => finding.severity === "blocker"); + const warnings = findings.filter((finding) => finding.severity === "warning"); + const badge = + blockers.length > 0 + ? "badge-blocked" + : warnings.length > 0 || normalizedScore < 85 + ? "steward-review" + : "trusted-reviewer-eligible"; + const decision = + blockers.length > 0 + ? "block-assignment" + : warnings.length > 0 + ? "assign-with-lower-weight" + : "trusted-reviewer-ready"; + const weight = blockers.length > 0 ? 0 : Number((normalizedScore / 100).toFixed(2)); + + const result = { + assignmentId: assignment.id || `${assignment.manuscriptId}:${assignment.reviewerId}`, + manuscriptId: assignment.manuscriptId, + reviewerId: reviewer.id || assignment.reviewerId, + decision, + badge, + expertiseScore: normalizedScore, + reviewWeight: weight, + requiredDomains, + requiredMethods, + findings, + publicProfile: publicReviewerProfile(reviewer, assignment, normalizedScore, badge), + }; + + return { + ...result, + assignmentDigest: digest(result), + }; +} + +function evaluateReviewerExpertiseCredentials(packet = {}) { + const reviewersById = asArray(packet.reviewers).reduce((acc, reviewer) => { + if (reviewer && reviewer.id) acc[reviewer.id] = reviewer; + return acc; + }, {}); + const context = { + now: parseDate(packet.now || new Date().toISOString()), + maxEvidenceAgeDays: packet.maxEvidenceAgeDays || 730, + minimumAcceptedReviews: packet.minimumAcceptedReviews || 3, + }; + const assignments = asArray(packet.assignments).map((assignment) => evaluateAssignment(assignment, reviewersById, context)); + const counts = assignments.reduce( + (acc, assignment) => { + acc[assignment.decision] = (acc[assignment.decision] || 0) + 1; + acc.findings += assignment.findings.length; + return acc; + }, + { "trusted-reviewer-ready": 0, "assign-with-lower-weight": 0, "block-assignment": 0, findings: 0 }, + ); + const report = { + generatedAt: packet.now || new Date().toISOString(), + decision: counts["block-assignment"] > 0 ? "steward-intervention-required" : counts["assign-with-lower-weight"] > 0 ? "credential-review-needed" : "expertise-routing-ready", + counts, + assignments, + }; + + return { + ...report, + auditDigest: digest(report), + }; +} + +module.exports = { + evaluateReviewerExpertiseCredentials, + stableStringify, +}; diff --git a/reviewer-expertise-credential-guard/reports/reviewer-expertise-credential-report.json b/reviewer-expertise-credential-guard/reports/reviewer-expertise-credential-report.json new file mode 100644 index 00000000..c8190a6a --- /dev/null +++ b/reviewer-expertise-credential-guard/reports/reviewer-expertise-credential-report.json @@ -0,0 +1,96 @@ +{ + "generatedAt": "2026-06-01T12:00:00Z", + "decision": "steward-intervention-required", + "counts": { + "trusted-reviewer-ready": 1, + "assign-with-lower-weight": 0, + "block-assignment": 1, + "findings": 4 + }, + "assignments": [ + { + "assignmentId": "assign-protein", + "manuscriptId": "ms-protein-forecast", + "reviewerId": "reviewer-ada", + "decision": "trusted-reviewer-ready", + "badge": "trusted-reviewer-eligible", + "expertiseScore": 100, + "reviewWeight": 1, + "requiredDomains": [ + "proteomics", + "machine-learning" + ], + "requiredMethods": [ + "bayesian-modeling" + ], + "findings": [], + "publicProfile": { + "reviewerHash": "9f670a60af1c5a0d", + "expertiseTags": [ + "proteomics", + "machine-learning", + "bayesian-modeling", + "mass-spectrometry" + ], + "expertiseScore": 100, + "badge": "trusted-reviewer-eligible", + "identityRedacted": true + }, + "assignmentDigest": "b24195e29509d3f274f45b7eed4c20001a6c6327388b2ecb23b051032a635bb4" + }, + { + "assignmentId": "assign-materials", + "manuscriptId": "ms-materials-benchmark", + "reviewerId": "reviewer-byron", + "decision": "block-assignment", + "badge": "badge-blocked", + "expertiseScore": 30, + "reviewWeight": 0, + "requiredDomains": [ + "materials-science" + ], + "requiredMethods": [ + "electron-microscopy" + ], + "findings": [ + { + "severity": "blocker", + "code": "REVIEWER_CONFLICT_DETECTED", + "message": "Reviewer has a recent-coauthor conflict with this manuscript.", + "remediation": "Block weighted review credit until a steward resolves or reassigns the review." + }, + { + "severity": "warning", + "code": "DOMAIN_EVIDENCE_STALE", + "message": "Reviewer evidence for materials-science is older than 730 days.", + "remediation": "Refresh the credential evidence before using it for weighted reputation." + }, + { + "severity": "warning", + "code": "METHOD_EVIDENCE_GAP", + "message": "Reviewer does not have current evidence for required method electron-microscopy.", + "remediation": "Lower review weight or request a second reviewer with method-specific credentials." + }, + { + "severity": "info", + "code": "REVIEW_HISTORY_LIGHT", + "message": "Reviewer has a short accepted-review history for trusted badge elevation.", + "remediation": "Use steward review before granting a persistent expertise badge." + } + ], + "publicProfile": { + "reviewerId": "reviewer-byron", + "displayName": "Byron Reviewer", + "expertiseTags": [ + "materials-science", + "density-functional-theory" + ], + "expertiseScore": 30, + "badge": "badge-blocked", + "identityRedacted": false + }, + "assignmentDigest": "7c155f761071703d2621d08f53baa27406964bd9f07b3cf904a377bfa86f0bc6" + } + ], + "auditDigest": "3cb70effca2a0f1e197985e28369161e034e4a246dffcc30ae0ec116c747b820" +} \ No newline at end of file diff --git a/reviewer-expertise-credential-guard/reports/reviewer-expertise-credential-report.md b/reviewer-expertise-credential-guard/reports/reviewer-expertise-credential-report.md new file mode 100644 index 00000000..519c4ac8 --- /dev/null +++ b/reviewer-expertise-credential-guard/reports/reviewer-expertise-credential-report.md @@ -0,0 +1,21 @@ +# Reviewer Expertise Credential Guard Demo + +Decision: steward-intervention-required +Audit digest: 3cb70effca2a0f1e197985e28369161e034e4a246dffcc30ae0ec116c747b820 + +## Assignment Decisions + +- assign-protein: trusted-reviewer-ready; badge trusted-reviewer-eligible; weight 1 +- assign-materials: block-assignment; badge badge-blocked; weight 0 + +## Findings + +- assign-materials: blocker REVIEWER_CONFLICT_DETECTED - Reviewer has a recent-coauthor conflict with this manuscript. +- assign-materials: warning DOMAIN_EVIDENCE_STALE - Reviewer evidence for materials-science is older than 730 days. +- assign-materials: warning METHOD_EVIDENCE_GAP - Reviewer does not have current evidence for required method electron-microscopy. +- assign-materials: info REVIEW_HISTORY_LIGHT - Reviewer has a short accepted-review history for trusted badge elevation. + +## Public Profiles + +- assign-protein: {"reviewerHash":"9f670a60af1c5a0d","expertiseTags":["proteomics","machine-learning","bayesian-modeling","mass-spectrometry"],"expertiseScore":100,"badge":"trusted-reviewer-eligible","identityRedacted":true} +- assign-materials: {"reviewerId":"reviewer-byron","displayName":"Byron Reviewer","expertiseTags":["materials-science","density-functional-theory"],"expertiseScore":30,"badge":"badge-blocked","identityRedacted":false} diff --git a/reviewer-expertise-credential-guard/requirements-map.md b/reviewer-expertise-credential-guard/requirements-map.md new file mode 100644 index 00000000..d4544c38 --- /dev/null +++ b/reviewer-expertise-credential-guard/requirements-map.md @@ -0,0 +1,15 @@ +# Requirements Map + +Issue #15 asks for community and user reputation features, including reputation metrics, badges, user profiles, peer review, and trusted reviewer tiers. + +This submission focuses on a separate expertise credential lane: + +- Validates that reviewer domain claims are declared and backed by evidence. +- Checks method-specific evidence before assigning weighted review reputation. +- Detects stale credentials and routes them to steward refresh. +- Blocks weighted reviews when author, institution, funder, or project conflicts are present. +- Supports anonymous review by emitting redacted public reviewer profiles. +- Produces trusted-reviewer, steward-review, and badge-blocked outcomes. +- Emits deterministic assignment and report digests for reviewer replay. + +The scope is intentionally narrow so it does not overlap with generic reputation scoring, leaderboard eligibility, recusal, review civility, endorsement-ring, correction-impact, or badge-renewal submissions. diff --git a/reviewer-expertise-credential-guard/test.js b/reviewer-expertise-credential-guard/test.js new file mode 100644 index 00000000..1f9552bc --- /dev/null +++ b/reviewer-expertise-credential-guard/test.js @@ -0,0 +1,105 @@ +const assert = require("assert"); +const { evaluateReviewerExpertiseCredentials } = require("./index"); + +function basePacket(overrides = {}) { + return { + now: "2026-06-01T12:00:00Z", + maxEvidenceAgeDays: 730, + minimumAcceptedReviews: 3, + reviewers: [ + { + id: "reviewer-ada", + displayName: "Ada Reviewer", + orcid: "0000-0002-1825-0097", + institution: "open-science-lab", + declaredDomains: ["proteomics", "machine-learning"], + declaredMethods: ["bayesian-modeling", "mass-spectrometry"], + acceptedReviews: 14, + evidence: [ + { type: "domain", domains: ["proteomics"], issuedAt: "2026-01-15T00:00:00Z", sourceId: "orcid-work-1" }, + { type: "domain", domains: ["machine-learning"], issuedAt: "2026-02-10T00:00:00Z", sourceId: "grant-ml-7" }, + { type: "method", methods: ["bayesian-modeling"], issuedAt: "2026-03-01T00:00:00Z", sourceId: "review-method-4" }, + { type: "method", methods: ["mass-spectrometry"], issuedAt: "2026-03-05T00:00:00Z", sourceId: "publication-ms-2" }, + ], + conflicts: [], + }, + ], + assignments: [ + { + id: "assign-1", + manuscriptId: "ms-protein-forecast", + projectId: "project-protein", + reviewerId: "reviewer-ada", + requiredDomains: ["proteomics", "machine-learning"], + requiredMethods: ["bayesian-modeling"], + anonymousMode: true, + authorIds: ["author-lin"], + institutions: ["northbridge-university"], + }, + ], + ...overrides, + }; +} + +function testTrustedReviewerEligibleWithCurrentEvidence() { + const result = evaluateReviewerExpertiseCredentials(basePacket()); + + assert.equal(result.decision, "expertise-routing-ready"); + assert.equal(result.assignments[0].decision, "trusted-reviewer-ready"); + assert.equal(result.assignments[0].badge, "trusted-reviewer-eligible"); + assert.equal(result.assignments[0].reviewWeight, 1); +} + +function testMissingDomainEvidenceRequiresStewardReview() { + const packet = basePacket(); + packet.reviewers[0].evidence = packet.reviewers[0].evidence.filter((item) => !item.domains || !item.domains.includes("machine-learning")); + const result = evaluateReviewerExpertiseCredentials(packet); + + assert.equal(result.decision, "credential-review-needed"); + assert.equal(result.assignments[0].decision, "assign-with-lower-weight"); + assert.ok(result.assignments[0].findings.some((finding) => finding.code === "DOMAIN_EVIDENCE_MISSING")); +} + +function testConflictBlocksWeightedReview() { + const packet = basePacket(); + packet.reviewers[0].conflicts = [{ authorId: "author-lin", type: "recent-coauthor" }]; + const result = evaluateReviewerExpertiseCredentials(packet); + + assert.equal(result.decision, "steward-intervention-required"); + assert.equal(result.assignments[0].decision, "block-assignment"); + assert.equal(result.assignments[0].reviewWeight, 0); + assert.ok(result.assignments[0].findings.some((finding) => finding.code === "REVIEWER_CONFLICT_DETECTED")); +} + +function testStaleEvidenceRequiresRefresh() { + const packet = basePacket({ now: "2028-06-01T12:00:00Z" }); + const result = evaluateReviewerExpertiseCredentials(packet); + + assert.equal(result.decision, "credential-review-needed"); + assert.ok(result.assignments[0].findings.some((finding) => finding.code === "DOMAIN_EVIDENCE_STALE")); +} + +function testAnonymousProfileRedactsIdentity() { + const result = evaluateReviewerExpertiseCredentials(basePacket()); + const profile = result.assignments[0].publicProfile; + + assert.equal(profile.identityRedacted, true); + assert.ok(profile.reviewerHash); + assert.equal(profile.displayName, undefined); + assert.equal(profile.orcid, undefined); +} + +function testDeterministicDigest() { + const first = evaluateReviewerExpertiseCredentials(basePacket()); + const second = evaluateReviewerExpertiseCredentials(basePacket()); + assert.equal(first.auditDigest, second.auditDigest); +} + +testTrustedReviewerEligibleWithCurrentEvidence(); +testMissingDomainEvidenceRequiresStewardReview(); +testConflictBlocksWeightedReview(); +testStaleEvidenceRequiresRefresh(); +testAnonymousProfileRedactsIdentity(); +testDeterministicDigest(); + +console.log("reviewer-expertise-credential-guard tests passed");