diff --git a/scientific-bounty-reviewer-capacity-guard/README.md b/scientific-bounty-reviewer-capacity-guard/README.md new file mode 100644 index 00000000..4772dbfa --- /dev/null +++ b/scientific-bounty-reviewer-capacity-guard/README.md @@ -0,0 +1,22 @@ +# Scientific bounty reviewer capacity guard + +This module checks whether a scientific bounty has enough qualified, available reviewers before a review panel is assigned. + +It focuses on one slice of the bounty workflow: reviewer capacity. It does not handle payouts, arbitration, escrow, IP transfer, or export control. + +## What it checks + +- enough eligible reviewers +- required expertise coverage +- reviewer conflicts +- overloaded reviewers +- stale reviewer training + +## Run it + +```bash +node scientific-bounty-reviewer-capacity-guard/test.js +node scientific-bounty-reviewer-capacity-guard/demo.js +``` + +The demo writes a JSON result and Markdown report to `artifacts/`. diff --git a/scientific-bounty-reviewer-capacity-guard/artifacts/demo.gif b/scientific-bounty-reviewer-capacity-guard/artifacts/demo.gif new file mode 100644 index 00000000..707c1efe Binary files /dev/null and b/scientific-bounty-reviewer-capacity-guard/artifacts/demo.gif differ diff --git a/scientific-bounty-reviewer-capacity-guard/artifacts/demo.mp4 b/scientific-bounty-reviewer-capacity-guard/artifacts/demo.mp4 new file mode 100644 index 00000000..a3932d6a Binary files /dev/null and b/scientific-bounty-reviewer-capacity-guard/artifacts/demo.mp4 differ diff --git a/scientific-bounty-reviewer-capacity-guard/artifacts/reviewer-capacity-report.md b/scientific-bounty-reviewer-capacity-guard/artifacts/reviewer-capacity-report.md new file mode 100644 index 00000000..07f66a80 --- /dev/null +++ b/scientific-bounty-reviewer-capacity-guard/artifacts/reviewer-capacity-report.md @@ -0,0 +1,24 @@ +# Scientific bounty reviewer capacity report + +## BIO-42: Biomarker scoring challenge +Decision: ASSIGN_PANEL +Eligible reviewers: r-ada, r-lin +Next step: assign the listed reviewers and open the review window + +## MAT-11: Low heat cement materials challenge +Decision: HOLD_ASSIGNMENT +Eligible reviewers: r-ivy +Next step: hold assignment until coverage and capacity are fixed +Blockers: +- not enough eligible reviewers +- conflicts: r-noah +- overloaded: r-mira + +## QNT-7: Quantum noise reduction challenge +Decision: HOLD_ASSIGNMENT +Eligible reviewers: r-june +Next step: hold assignment until coverage and capacity are fixed +Blockers: +- not enough eligible reviewers +- missing expertise: quantum, signal-processing +- stale training: r-sam diff --git a/scientific-bounty-reviewer-capacity-guard/artifacts/reviewer-capacity-results.json b/scientific-bounty-reviewer-capacity-guard/artifacts/reviewer-capacity-results.json new file mode 100644 index 00000000..e270d404 --- /dev/null +++ b/scientific-bounty-reviewer-capacity-guard/artifacts/reviewer-capacity-results.json @@ -0,0 +1,47 @@ +[ + { + "challengeId": "BIO-42", + "title": "Biomarker scoring challenge", + "decision": "ASSIGN_PANEL", + "eligibleReviewers": [ + "r-ada", + "r-lin" + ], + "missingExpertise": [], + "blockers": [], + "nextStep": "assign the listed reviewers and open the review window" + }, + { + "challengeId": "MAT-11", + "title": "Low heat cement materials challenge", + "decision": "HOLD_ASSIGNMENT", + "eligibleReviewers": [ + "r-ivy" + ], + "missingExpertise": [], + "blockers": [ + "not enough eligible reviewers", + "conflicts: r-noah", + "overloaded: r-mira" + ], + "nextStep": "hold assignment until coverage and capacity are fixed" + }, + { + "challengeId": "QNT-7", + "title": "Quantum noise reduction challenge", + "decision": "HOLD_ASSIGNMENT", + "eligibleReviewers": [ + "r-june" + ], + "missingExpertise": [ + "quantum", + "signal-processing" + ], + "blockers": [ + "not enough eligible reviewers", + "missing expertise: quantum, signal-processing", + "stale training: r-sam" + ], + "nextStep": "hold assignment until coverage and capacity are fixed" + } +] \ No newline at end of file diff --git a/scientific-bounty-reviewer-capacity-guard/demo.js b/scientific-bounty-reviewer-capacity-guard/demo.js new file mode 100644 index 00000000..e783a11c --- /dev/null +++ b/scientific-bounty-reviewer-capacity-guard/demo.js @@ -0,0 +1,13 @@ +const fs = require("fs"); +const path = require("path"); +const challenges = require("./sample-data.json"); +const { evaluatePortfolio, renderMarkdownReport } = require("./index"); + +const artifactsDir = path.join(__dirname, "artifacts"); +fs.mkdirSync(artifactsDir, { recursive: true }); + +const results = evaluatePortfolio(challenges); +fs.writeFileSync(path.join(artifactsDir, "reviewer-capacity-results.json"), JSON.stringify(results, null, 2)); +fs.writeFileSync(path.join(artifactsDir, "reviewer-capacity-report.md"), renderMarkdownReport(results)); + +console.log(renderMarkdownReport(results)); diff --git a/scientific-bounty-reviewer-capacity-guard/index.js b/scientific-bounty-reviewer-capacity-guard/index.js new file mode 100644 index 00000000..fcb65761 --- /dev/null +++ b/scientific-bounty-reviewer-capacity-guard/index.js @@ -0,0 +1,81 @@ +const REQUIRED_REVIEWERS = 2; + +function scoreChallenge(challenge) { + const requiredExpertise = new Set(challenge.requiredExpertise || []); + const reviewers = challenge.reviewers || []; + + const eligible = reviewers.filter((reviewer) => { + const hasConflict = (reviewer.conflicts || []).includes(challenge.id); + const isOverloaded = reviewer.activeReviews >= reviewer.maxActiveReviews; + const coversExpertise = (reviewer.expertise || []).some((item) => requiredExpertise.has(item)); + return !hasConflict && !isOverloaded && reviewer.trainingCurrent && reviewer.available; + }); + + const coveredExpertise = new Set(); + for (const reviewer of eligible) { + for (const item of reviewer.expertise || []) { + if (requiredExpertise.has(item)) coveredExpertise.add(item); + } + } + + const missingExpertise = [...requiredExpertise].filter((item) => !coveredExpertise.has(item)); + const overloaded = reviewers.filter((reviewer) => reviewer.activeReviews >= reviewer.maxActiveReviews); + const conflicted = reviewers.filter((reviewer) => (reviewer.conflicts || []).includes(challenge.id)); + const staleTraining = reviewers.filter((reviewer) => !reviewer.trainingCurrent); + + const blockers = []; + if (eligible.length < REQUIRED_REVIEWERS) blockers.push("not enough eligible reviewers"); + if (missingExpertise.length) blockers.push(`missing expertise: ${missingExpertise.join(", ")}`); + if (conflicted.length) blockers.push(`conflicts: ${conflicted.map((r) => r.id).join(", ")}`); + if (overloaded.length) blockers.push(`overloaded: ${overloaded.map((r) => r.id).join(", ")}`); + if (staleTraining.length) blockers.push(`stale training: ${staleTraining.map((r) => r.id).join(", ")}`); + + let decision = "ASSIGN_PANEL"; + if (eligible.length < REQUIRED_REVIEWERS || missingExpertise.length) { + decision = "HOLD_ASSIGNMENT"; + } else if (conflicted.length || overloaded.length || staleTraining.length) { + decision = "REBALANCE_PANEL"; + } + + return { + challengeId: challenge.id, + title: challenge.title, + decision, + eligibleReviewers: eligible.map((reviewer) => reviewer.id), + missingExpertise, + blockers, + nextStep: nextStepFor(decision), + }; +} + +function nextStepFor(decision) { + if (decision === "ASSIGN_PANEL") return "assign the listed reviewers and open the review window"; + if (decision === "REBALANCE_PANEL") return "rebalance reviewer load or replace conflicted reviewers"; + return "hold assignment until coverage and capacity are fixed"; +} + +function evaluatePortfolio(challenges) { + return challenges.map(scoreChallenge); +} + +function renderMarkdownReport(results) { + const lines = ["# Scientific bounty reviewer capacity report", ""]; + for (const result of results) { + lines.push(`## ${result.challengeId}: ${result.title}`); + lines.push(`Decision: ${result.decision}`); + lines.push(`Eligible reviewers: ${result.eligibleReviewers.join(", ") || "none"}`); + lines.push(`Next step: ${result.nextStep}`); + if (result.blockers.length) { + lines.push("Blockers:"); + for (const blocker of result.blockers) lines.push(`- ${blocker}`); + } + lines.push(""); + } + return lines.join("\n"); +} + +module.exports = { + evaluatePortfolio, + renderMarkdownReport, + scoreChallenge, +}; diff --git a/scientific-bounty-reviewer-capacity-guard/sample-data.json b/scientific-bounty-reviewer-capacity-guard/sample-data.json new file mode 100644 index 00000000..5fe78417 --- /dev/null +++ b/scientific-bounty-reviewer-capacity-guard/sample-data.json @@ -0,0 +1,86 @@ +[ + { + "id": "BIO-42", + "title": "Biomarker scoring challenge", + "requiredExpertise": ["bioinformatics", "statistics"], + "reviewers": [ + { + "id": "r-ada", + "expertise": ["bioinformatics"], + "activeReviews": 1, + "maxActiveReviews": 4, + "conflicts": [], + "trainingCurrent": true, + "available": true + }, + { + "id": "r-lin", + "expertise": ["statistics", "ml"], + "activeReviews": 2, + "maxActiveReviews": 4, + "conflicts": [], + "trainingCurrent": true, + "available": true + } + ] + }, + { + "id": "MAT-11", + "title": "Low heat cement materials challenge", + "requiredExpertise": ["materials", "climate"], + "reviewers": [ + { + "id": "r-mira", + "expertise": ["materials"], + "activeReviews": 4, + "maxActiveReviews": 4, + "conflicts": [], + "trainingCurrent": true, + "available": true + }, + { + "id": "r-noah", + "expertise": ["climate"], + "activeReviews": 1, + "maxActiveReviews": 3, + "conflicts": ["MAT-11"], + "trainingCurrent": true, + "available": true + }, + { + "id": "r-ivy", + "expertise": ["materials", "climate"], + "activeReviews": 1, + "maxActiveReviews": 3, + "conflicts": [], + "trainingCurrent": true, + "available": true + } + ] + }, + { + "id": "QNT-7", + "title": "Quantum noise reduction challenge", + "requiredExpertise": ["quantum", "signal-processing"], + "reviewers": [ + { + "id": "r-sam", + "expertise": ["quantum"], + "activeReviews": 2, + "maxActiveReviews": 3, + "conflicts": [], + "trainingCurrent": false, + "available": true + }, + { + "id": "r-june", + "expertise": ["statistics"], + "activeReviews": 0, + "maxActiveReviews": 2, + "conflicts": [], + "trainingCurrent": true, + "available": true + } + ] + } +] diff --git a/scientific-bounty-reviewer-capacity-guard/test.js b/scientific-bounty-reviewer-capacity-guard/test.js new file mode 100644 index 00000000..5aa05749 --- /dev/null +++ b/scientific-bounty-reviewer-capacity-guard/test.js @@ -0,0 +1,18 @@ +const assert = require("assert"); +const challenges = require("./sample-data.json"); +const { evaluatePortfolio } = require("./index"); + +const results = evaluatePortfolio(challenges); +const byId = Object.fromEntries(results.map((result) => [result.challengeId, result])); + +assert.strictEqual(byId["BIO-42"].decision, "ASSIGN_PANEL"); +assert.deepStrictEqual(byId["BIO-42"].eligibleReviewers, ["r-ada", "r-lin"]); + +assert.strictEqual(byId["MAT-11"].decision, "HOLD_ASSIGNMENT"); +assert.ok(byId["MAT-11"].blockers.some((blocker) => blocker.includes("conflicts"))); +assert.ok(byId["MAT-11"].blockers.some((blocker) => blocker.includes("overloaded"))); + +assert.strictEqual(byId["QNT-7"].decision, "HOLD_ASSIGNMENT"); +assert.deepStrictEqual(byId["QNT-7"].missingExpertise, ["quantum", "signal-processing"]); + +console.log("scientific bounty reviewer capacity guard tests passed");