diff --git a/reviewer-workload-equity-guard/README.md b/reviewer-workload-equity-guard/README.md new file mode 100644 index 00000000..871ade3e --- /dev/null +++ b/reviewer-workload-equity-guard/README.md @@ -0,0 +1,36 @@ +# Reviewer Workload Equity Guard + +Self-contained Community & User Reputation System slice for +`SCIBASE-AI/SCIBASE.AI#15`. + +The guard evaluates pending peer-review assignments before they affect profile +reputation, badges, leaderboards, or project timelines. It checks reviewer +capacity, weekly review hours, opt-outs, leave windows, rest periods, expertise +match, early-career penalty risk, and recent review-credit concentration. + +This is intentionally separate from broad reputation ledgers, endorsement rings, +leaderboard eligibility, review civility, review timeliness scoring, recusal/COI, +calibration benches, edit-history integrity, identity-leak checks, appeals, +mentorship, correction-impact, credit attestation, profile visibility, and +template-rubric validation. Its job is to stop unfair negative reputation deltas +when the reviewer was overloaded, unavailable, or mismatched before scoring. + +## Run + +```bash +npm run check +npm test +npm run demo +npm run demo:video +``` + +## Outputs + +- `reports/summary.json` +- `reports/reviewer-packet.md` +- `reports/summary.svg` +- `reports/demo.webm` + +All data is synthetic. The module does not call identity services, profile +systems, leaderboards, email systems, review assignment systems, or external +APIs. diff --git a/reviewer-workload-equity-guard/demo-video.js b/reviewer-workload-equity-guard/demo-video.js new file mode 100644 index 00000000..0b0e51f7 --- /dev/null +++ b/reviewer-workload-equity-guard/demo-video.js @@ -0,0 +1,173 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const reportDir = path.join(__dirname, "reports"); +const outputPath = path.join(reportDir, "demo.webm"); + +const chromeCandidates = [ + process.env.CHROME_PATH, + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" +].filter(Boolean); + +function findBrowser() { + const found = chromeCandidates.find((candidate) => fs.existsSync(candidate)); + if (!found) { + throw new Error("Chrome or Edge was not found. Set CHROME_PATH to generate reports/demo.webm."); + } + return found; +} + +function fileUrl(filePath) { + return `file:///${filePath.replace(/\\/g, "/")}`; +} + +const html = String.raw` + + + + Reviewer workload equity guard demo + + + + +
recording
+ + +`; + +fs.mkdirSync(reportDir, { recursive: true }); + +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "reviewer-workload-demo-")); +const htmlPath = path.join(tempDir, "demo.html"); +const profileDir = path.join(tempDir, "profile"); +fs.writeFileSync(htmlPath, html, "utf8"); + +const stdout = execFileSync( + findBrowser(), + [ + "--headless=new", + "--disable-gpu", + "--disable-dev-shm-usage", + "--autoplay-policy=no-user-gesture-required", + "--run-all-compositor-stages-before-draw", + "--virtual-time-budget=7500", + `--user-data-dir=${profileDir}`, + "--dump-dom", + fileUrl(htmlPath) + ], + { encoding: "utf8", maxBuffer: 30 * 1024 * 1024 } +); + +const match = stdout.match(/data:video\/webm;base64,([A-Za-z0-9+/=]+)/); +if (!match) { + throw new Error(`Demo video generation failed. Browser output ended with: ${stdout.slice(-600)}`); +} + +fs.writeFileSync(outputPath, Buffer.from(match[1], "base64")); +console.log(`Generated ${path.relative(process.cwd(), outputPath)}`); diff --git a/reviewer-workload-equity-guard/demo.js b/reviewer-workload-equity-guard/demo.js new file mode 100644 index 00000000..97c3730c --- /dev/null +++ b/reviewer-workload-equity-guard/demo.js @@ -0,0 +1,18 @@ +const fs = require("fs"); +const path = require("path"); +const { project } = require("./sample-data"); +const { buildReviewPacket, renderMarkdownReport, renderSvgSummary } = require("./index"); + +const reportDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportDir, { recursive: true }); + +const packet = buildReviewPacket(project); + +fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(packet, null, 2)}\n`, "utf8"); +fs.writeFileSync(path.join(reportDir, "reviewer-packet.md"), renderMarkdownReport(packet), "utf8"); +fs.writeFileSync(path.join(reportDir, "summary.svg"), renderSvgSummary(packet), "utf8"); + +console.log(`Generated reports for ${packet.guard}`); +console.log(`Decision: ${packet.decision}`); +console.log(`Score: ${packet.score}`); +console.log(`Findings: ${packet.findings.length}`); diff --git a/reviewer-workload-equity-guard/index.js b/reviewer-workload-equity-guard/index.js new file mode 100644 index 00000000..2225daaa --- /dev/null +++ b/reviewer-workload-equity-guard/index.js @@ -0,0 +1,368 @@ +const SEVERITY_WEIGHTS = { + critical: 34, + high: 20, + medium: 10, + low: 4 +}; + +function daysBetween(a, b) { + const left = new Date(a).getTime(); + const right = new Date(b).getTime(); + return Math.floor((right - left) / (24 * 60 * 60 * 1000)); +} + +function addFinding(findings, severity, rule, message, action, refs = []) { + findings.push({ severity, rule, message, action, refs }); +} + +function isDateWithinWindow(date, window) { + const value = new Date(date).getTime(); + return value >= new Date(window.startsAt).getTime() && value <= new Date(window.endsAt).getTime(); +} + +function findReviewer(project, reviewerId) { + return project.reviewers.find((reviewer) => reviewer.id === reviewerId); +} + +function workloadAfterAssignment(reviewer, assignment) { + return { + openReviews: reviewer.openReviewIds.length + 1, + weeklyReviewHours: reviewer.recentReviewHours + assignment.estimatedHours + }; +} + +function assignmentRiskReasons(project, reviewer, assignment) { + const reasons = []; + const projected = workloadAfterAssignment(reviewer, assignment); + + if (projected.openReviews > project.policy.maxOpenReviews) { + reasons.push("over_capacity"); + } + if (projected.weeklyReviewHours > Math.min(project.policy.maxWeeklyReviewHours, reviewer.weeklyCapacityHours)) { + reasons.push("weekly_hours_exceeded"); + } + if (!project.policy.acceptedAvailabilityStatuses.includes(reviewer.availabilityStatus)) { + reasons.push("unavailable"); + } + if (reviewer.optOutUntil && daysBetween(project.asOfDate, reviewer.optOutUntil) >= 0) { + reasons.push("opted_out"); + } + if (reviewer.lastCompletedReviewAt && daysBetween(reviewer.lastCompletedReviewAt, project.asOfDate) < project.policy.minimumRestDays) { + reasons.push("rest_window"); + } + if (reviewer.unavailableWindows.some((window) => isDateWithinWindow(assignment.dueDate, window))) { + reasons.push("due_during_unavailable_window"); + } + if (!reviewer.expertise.includes(assignment.topic)) { + reasons.push("expertise_mismatch"); + } + + return reasons; +} + +function buildAssignmentDecision(project, reviewer, assignment, reasons) { + const protectedReasons = reasons.filter((reason) => project.policy.protectedPenaltyReasons.includes(reason)); + if (protectedReasons.length > 0) { + return { + assignmentId: assignment.id, + reviewerId: reviewer.id, + decision: "suppress-negative-reputation-delta", + protectedReasons, + allowedCompletionDelta: assignment.reputationDelta.completion, + blockedDeclineDelta: assignment.reputationDelta.decline, + blockedLateDelta: assignment.reputationDelta.late + }; + } + + if (reasons.includes("weekly_hours_exceeded") || reasons.includes("due_during_unavailable_window")) { + return { + assignmentId: assignment.id, + reviewerId: reviewer.id, + decision: "steward-review-before-scoring", + protectedReasons: reasons, + allowedCompletionDelta: 0, + blockedDeclineDelta: assignment.reputationDelta.decline, + blockedLateDelta: assignment.reputationDelta.late + }; + } + + if (reasons.includes("expertise_mismatch")) { + return { + assignmentId: assignment.id, + reviewerId: reviewer.id, + decision: "reassign-or-add-mentor-before-scoring", + protectedReasons: reasons, + allowedCompletionDelta: 0, + blockedDeclineDelta: assignment.reputationDelta.decline, + blockedLateDelta: assignment.reputationDelta.late + }; + } + + return { + assignmentId: assignment.id, + reviewerId: reviewer.id, + decision: "score-normally", + protectedReasons: [], + allowedCompletionDelta: assignment.reputationDelta.completion, + blockedDeclineDelta: 0, + blockedLateDelta: 0 + }; +} + +function concentrationSummary(project) { + const recent = project.recentAssignmentHistory.filter( + (item) => daysBetween(item.assignedAt, project.asOfDate) <= project.policy.concentrationWindowDays + ); + const byReviewer = recent.reduce((summary, item) => { + summary[item.reviewerId] = (summary[item.reviewerId] || 0) + 1; + return summary; + }, {}); + const total = recent.length || 1; + const entries = Object.entries(byReviewer).map(([reviewerId, count]) => ({ + reviewerId, + count, + ratio: count / total + })); + entries.sort((a, b) => b.ratio - a.ratio); + return { total: recent.length, entries }; +} + +function evaluateWorkloadEquity(project) { + const findings = []; + const decisions = []; + + for (const assignment of project.pendingAssignments) { + const reviewer = findReviewer(project, assignment.reviewerId); + if (!reviewer) { + addFinding( + findings, + "critical", + "assignment-reviewer-missing", + `Assignment ${assignment.id} references missing reviewer ${assignment.reviewerId}.`, + "Block reputation scoring until the assignment is repaired or removed.", + [assignment.id, assignment.reviewerId] + ); + continue; + } + + const projected = workloadAfterAssignment(reviewer, assignment); + const reasons = assignmentRiskReasons(project, reviewer, assignment); + decisions.push(buildAssignmentDecision(project, reviewer, assignment, reasons)); + + if (projected.openReviews > project.policy.maxOpenReviews) { + addFinding( + findings, + "high", + "open-review-load-exceeded", + `${reviewer.displayName} would hold ${projected.openReviews} open reviews after ${assignment.id}.`, + "Reassign or queue the request before applying reputation penalties or leaderboard effects.", + [reviewer.id, assignment.id] + ); + } + + if (projected.weeklyReviewHours > Math.min(project.policy.maxWeeklyReviewHours, reviewer.weeklyCapacityHours)) { + addFinding( + findings, + "high", + "weekly-review-hour-budget-exceeded", + `${reviewer.displayName} would reach ${projected.weeklyReviewHours} review hours this week.`, + "Suppress negative reputation deltas and route the request to a reviewer with available capacity.", + [reviewer.id, assignment.id] + ); + } + + if (!project.policy.acceptedAvailabilityStatuses.includes(reviewer.availabilityStatus)) { + addFinding( + findings, + "critical", + "reviewer-unavailable-but-penalized", + `${reviewer.displayName} is ${reviewer.availabilityStatus} but ${assignment.id} still carries decline and late penalties.`, + "Block decline/late reputation penalties while the reviewer is unavailable.", + [reviewer.id, assignment.id] + ); + } + + if (reviewer.optOutUntil && daysBetween(project.asOfDate, reviewer.optOutUntil) >= 0) { + addFinding( + findings, + "critical", + "reviewer-opt-out-active", + `${reviewer.displayName} has opted out until ${reviewer.optOutUntil}.`, + "Do not assign reputation-affecting reviews until the opt-out expires.", + [reviewer.id, assignment.id, reviewer.optOutUntil] + ); + } + + if (reviewer.lastCompletedReviewAt && daysBetween(reviewer.lastCompletedReviewAt, project.asOfDate) < project.policy.minimumRestDays) { + addFinding( + findings, + "medium", + "reviewer-rest-window-too-short", + `${reviewer.displayName} completed a review on ${reviewer.lastCompletedReviewAt}.`, + "Defer the new request or remove negative reputation effects until the rest window is met.", + [reviewer.id, assignment.id] + ); + } + + if (reviewer.unavailableWindows.some((window) => isDateWithinWindow(assignment.dueDate, window))) { + addFinding( + findings, + "high", + "assignment-due-during-unavailable-window", + `${assignment.id} is due while ${reviewer.displayName} is unavailable.`, + "Move the due date or reassign before the review can change reputation points.", + [reviewer.id, assignment.id, assignment.dueDate] + ); + } + + if (!reviewer.expertise.includes(assignment.topic)) { + addFinding( + findings, + reviewer.trustTier === "early-career" ? "high" : "medium", + "reviewer-expertise-mismatch", + `${assignment.id} topic ${assignment.topic} is outside ${reviewer.displayName}'s declared expertise.`, + "Add a mentor, reassign the review, or prevent reputation penalties from mismatched work.", + [reviewer.id, assignment.id, assignment.topic] + ); + } + + if (reviewer.trustTier === "early-career" && (assignment.reputationDelta.decline < -4 || assignment.reputationDelta.late < -6)) { + addFinding( + findings, + "medium", + "early-career-reviewer-high-penalty-risk", + `${assignment.id} applies high negative deltas to an early-career reviewer.`, + "Route the assignment through mentor review before applying profile or leaderboard penalties.", + [reviewer.id, assignment.id] + ); + } + } + + const concentration = concentrationSummary(project); + const top = concentration.entries[0]; + if (top && top.ratio > project.policy.maxReviewerConcentrationRatio) { + addFinding( + findings, + "medium", + "review-credit-concentration-too-high", + `${top.reviewerId} received ${Math.round(top.ratio * 100)}% of recent review credit.`, + "Spread review opportunities before awarding additional leaderboard-affecting points.", + [top.reviewerId] + ); + } + + const severitySummary = findings.reduce( + (summary, finding) => { + summary[finding.severity] += 1; + return summary; + }, + { critical: 0, high: 0, medium: 0, low: 0 } + ); + const score = Math.max(0, 100 - findings.reduce((sum, finding) => sum + SEVERITY_WEIGHTS[finding.severity], 0)); + + return { findings, decisions, concentration, severitySummary, score }; +} + +function decisionFromEvaluation(evaluation) { + if (evaluation.severitySummary.critical > 0) { + return "block-reputation-scoring-until-workload-is-fair"; + } + if (evaluation.severitySummary.high > 0 || evaluation.score < 75) { + return "hold-leaderboard-and-profile-deltas-for-steward-review"; + } + if (evaluation.score < 90) { + return "manual-equity-review-before-scoring"; + } + return "workload-equity-ready"; +} + +function buildReviewPacket(project) { + const evaluation = evaluateWorkloadEquity(project); + return { + guard: "reviewer-workload-equity-guard", + issue: "SCIBASE-AI/SCIBASE.AI#15", + asOfDate: project.asOfDate, + decision: decisionFromEvaluation(evaluation), + score: evaluation.score, + severitySummary: evaluation.severitySummary, + findings: evaluation.findings, + assignmentDecisions: evaluation.decisions, + concentration: evaluation.concentration, + safety: [ + "Synthetic reviewer, assignment, availability, and reputation data only", + "No profile writes, leaderboard writes, identity calls, email calls, or external review system calls", + "No private reviewer identities, credentials, moderation records, or live reputation mutations" + ] + }; +} + +function renderMarkdownReport(packet) { + const lines = [ + "# Reviewer Workload Equity Guard", + "", + `Issue: ${packet.issue}`, + `Decision: ${packet.decision}`, + `Score: ${packet.score}`, + "", + "## Severity Summary", + "", + "| Severity | Count |", + "| --- | ---: |" + ]; + + for (const severity of ["critical", "high", "medium", "low"]) { + lines.push(`| ${severity} | ${packet.severitySummary[severity]} |`); + } + + lines.push("", "## Assignment Decisions", ""); + for (const decision of packet.assignmentDecisions) { + lines.push(`- ${decision.assignmentId}: ${decision.decision}`); + if (decision.protectedReasons.length > 0) { + lines.push(` - Protected reasons: ${decision.protectedReasons.join(", ")}`); + } + } + + lines.push("", "## Findings", ""); + for (const finding of packet.findings) { + lines.push(`- **${finding.severity} / ${finding.rule}**: ${finding.message}`); + lines.push(` - Action: ${finding.action}`); + lines.push(` - Refs: ${finding.refs.join(", ") || "none"}`); + } + + lines.push("", "## Safety", ""); + for (const item of packet.safety) { + lines.push(`- ${item}`); + } + + return `${lines.join("\n")}\n`; +} + +function renderSvgSummary(packet) { + const scoreWidth = Math.max(44, Math.min(760, packet.score * 7.6)); + return ` + + Reviewer Workload Equity Guard + SCIBASE #15 reputation scoring fairness checkpoint + + ${packet.decision} + Critical ${packet.severitySummary.critical} | High ${packet.severitySummary.high} | Findings ${packet.findings.length} + + Equity score + + + ${packet.score}/100 + + Suppress unfair negative deltas + Checks capacity, opt-outs, rest windows, availability, expertise, and credit concentration before scoring. + +`; +} + +module.exports = { + assignmentRiskReasons, + buildReviewPacket, + decisionFromEvaluation, + evaluateWorkloadEquity, + renderMarkdownReport, + renderSvgSummary +}; diff --git a/reviewer-workload-equity-guard/package.json b/reviewer-workload-equity-guard/package.json new file mode 100644 index 00000000..6f587c80 --- /dev/null +++ b/reviewer-workload-equity-guard/package.json @@ -0,0 +1,14 @@ +{ + "name": "reviewer-workload-equity-guard", + "version": "1.0.0", + "description": "Deterministic reputation fairness guard for peer-review workload and availability.", + "main": "index.js", + "private": true, + "type": "commonjs", + "scripts": { + "check": "node --check index.js && node --check sample-data.js && node --check demo.js && node --check demo-video.js && node --check test.js", + "test": "node test.js", + "demo": "node demo.js", + "demo:video": "node demo-video.js" + } +} diff --git a/reviewer-workload-equity-guard/reports/demo.webm b/reviewer-workload-equity-guard/reports/demo.webm new file mode 100644 index 00000000..e646fac7 Binary files /dev/null and b/reviewer-workload-equity-guard/reports/demo.webm differ diff --git a/reviewer-workload-equity-guard/reports/reviewer-packet.md b/reviewer-workload-equity-guard/reports/reviewer-packet.md new file mode 100644 index 00000000..38c49072 --- /dev/null +++ b/reviewer-workload-equity-guard/reports/reviewer-packet.md @@ -0,0 +1,65 @@ +# Reviewer Workload Equity Guard + +Issue: SCIBASE-AI/SCIBASE.AI#15 +Decision: block-reputation-scoring-until-workload-is-fair +Score: 0 + +## Severity Summary + +| Severity | Count | +| --- | ---: | +| critical | 2 | +| high | 6 | +| medium | 3 | +| low | 0 | + +## Assignment Decisions + +- asg-105: suppress-negative-reputation-delta + - Protected reasons: over_capacity, rest_window +- asg-106: steward-review-before-scoring + - Protected reasons: weekly_hours_exceeded, expertise_mismatch +- asg-107: suppress-negative-reputation-delta + - Protected reasons: unavailable, opted_out + +## Findings + +- **high / open-review-load-exceeded**: A. Rowan would hold 4 open reviews after asg-105. + - Action: Reassign or queue the request before applying reputation penalties or leaderboard effects. + - Refs: rev-001, asg-105 +- **high / weekly-review-hour-budget-exceeded**: A. Rowan would reach 13 review hours this week. + - Action: Suppress negative reputation deltas and route the request to a reviewer with available capacity. + - Refs: rev-001, asg-105 +- **medium / reviewer-rest-window-too-short**: A. Rowan completed a review on 2026-05-21. + - Action: Defer the new request or remove negative reputation effects until the rest window is met. + - Refs: rev-001, asg-105 +- **high / assignment-due-during-unavailable-window**: asg-105 is due while A. Rowan is unavailable. + - Action: Move the due date or reassign before the review can change reputation points. + - Refs: rev-001, asg-105, 2026-05-24 +- **high / weekly-review-hour-budget-exceeded**: M. Quinn would reach 10 review hours this week. + - Action: Suppress negative reputation deltas and route the request to a reviewer with available capacity. + - Refs: rev-002, asg-106 +- **high / reviewer-expertise-mismatch**: asg-106 topic quantum-simulation is outside M. Quinn's declared expertise. + - Action: Add a mentor, reassign the review, or prevent reputation penalties from mismatched work. + - Refs: rev-002, asg-106, quantum-simulation +- **medium / early-career-reviewer-high-penalty-risk**: asg-106 applies high negative deltas to an early-career reviewer. + - Action: Route the assignment through mentor review before applying profile or leaderboard penalties. + - Refs: rev-002, asg-106 +- **critical / reviewer-unavailable-but-penalized**: S. Imani is unavailable but asg-107 still carries decline and late penalties. + - Action: Block decline/late reputation penalties while the reviewer is unavailable. + - Refs: rev-003, asg-107 +- **critical / reviewer-opt-out-active**: S. Imani has opted out until 2026-05-30. + - Action: Do not assign reputation-affecting reviews until the opt-out expires. + - Refs: rev-003, asg-107, 2026-05-30 +- **high / assignment-due-during-unavailable-window**: asg-107 is due while S. Imani is unavailable. + - Action: Move the due date or reassign before the review can change reputation points. + - Refs: rev-003, asg-107, 2026-05-26 +- **medium / review-credit-concentration-too-high**: rev-001 received 67% of recent review credit. + - Action: Spread review opportunities before awarding additional leaderboard-affecting points. + - Refs: rev-001 + +## Safety + +- Synthetic reviewer, assignment, availability, and reputation data only +- No profile writes, leaderboard writes, identity calls, email calls, or external review system calls +- No private reviewer identities, credentials, moderation records, or live reputation mutations diff --git a/reviewer-workload-equity-guard/reports/summary.json b/reviewer-workload-equity-guard/reports/summary.json new file mode 100644 index 00000000..7be59258 --- /dev/null +++ b/reviewer-workload-equity-guard/reports/summary.json @@ -0,0 +1,191 @@ +{ + "guard": "reviewer-workload-equity-guard", + "issue": "SCIBASE-AI/SCIBASE.AI#15", + "asOfDate": "2026-05-22", + "decision": "block-reputation-scoring-until-workload-is-fair", + "score": 0, + "severitySummary": { + "critical": 2, + "high": 6, + "medium": 3, + "low": 0 + }, + "findings": [ + { + "severity": "high", + "rule": "open-review-load-exceeded", + "message": "A. Rowan would hold 4 open reviews after asg-105.", + "action": "Reassign or queue the request before applying reputation penalties or leaderboard effects.", + "refs": [ + "rev-001", + "asg-105" + ] + }, + { + "severity": "high", + "rule": "weekly-review-hour-budget-exceeded", + "message": "A. Rowan would reach 13 review hours this week.", + "action": "Suppress negative reputation deltas and route the request to a reviewer with available capacity.", + "refs": [ + "rev-001", + "asg-105" + ] + }, + { + "severity": "medium", + "rule": "reviewer-rest-window-too-short", + "message": "A. Rowan completed a review on 2026-05-21.", + "action": "Defer the new request or remove negative reputation effects until the rest window is met.", + "refs": [ + "rev-001", + "asg-105" + ] + }, + { + "severity": "high", + "rule": "assignment-due-during-unavailable-window", + "message": "asg-105 is due while A. Rowan is unavailable.", + "action": "Move the due date or reassign before the review can change reputation points.", + "refs": [ + "rev-001", + "asg-105", + "2026-05-24" + ] + }, + { + "severity": "high", + "rule": "weekly-review-hour-budget-exceeded", + "message": "M. Quinn would reach 10 review hours this week.", + "action": "Suppress negative reputation deltas and route the request to a reviewer with available capacity.", + "refs": [ + "rev-002", + "asg-106" + ] + }, + { + "severity": "high", + "rule": "reviewer-expertise-mismatch", + "message": "asg-106 topic quantum-simulation is outside M. Quinn's declared expertise.", + "action": "Add a mentor, reassign the review, or prevent reputation penalties from mismatched work.", + "refs": [ + "rev-002", + "asg-106", + "quantum-simulation" + ] + }, + { + "severity": "medium", + "rule": "early-career-reviewer-high-penalty-risk", + "message": "asg-106 applies high negative deltas to an early-career reviewer.", + "action": "Route the assignment through mentor review before applying profile or leaderboard penalties.", + "refs": [ + "rev-002", + "asg-106" + ] + }, + { + "severity": "critical", + "rule": "reviewer-unavailable-but-penalized", + "message": "S. Imani is unavailable but asg-107 still carries decline and late penalties.", + "action": "Block decline/late reputation penalties while the reviewer is unavailable.", + "refs": [ + "rev-003", + "asg-107" + ] + }, + { + "severity": "critical", + "rule": "reviewer-opt-out-active", + "message": "S. Imani has opted out until 2026-05-30.", + "action": "Do not assign reputation-affecting reviews until the opt-out expires.", + "refs": [ + "rev-003", + "asg-107", + "2026-05-30" + ] + }, + { + "severity": "high", + "rule": "assignment-due-during-unavailable-window", + "message": "asg-107 is due while S. Imani is unavailable.", + "action": "Move the due date or reassign before the review can change reputation points.", + "refs": [ + "rev-003", + "asg-107", + "2026-05-26" + ] + }, + { + "severity": "medium", + "rule": "review-credit-concentration-too-high", + "message": "rev-001 received 67% of recent review credit.", + "action": "Spread review opportunities before awarding additional leaderboard-affecting points.", + "refs": [ + "rev-001" + ] + } + ], + "assignmentDecisions": [ + { + "assignmentId": "asg-105", + "reviewerId": "rev-001", + "decision": "suppress-negative-reputation-delta", + "protectedReasons": [ + "over_capacity", + "rest_window" + ], + "allowedCompletionDelta": 8, + "blockedDeclineDelta": -4, + "blockedLateDelta": -6 + }, + { + "assignmentId": "asg-106", + "reviewerId": "rev-002", + "decision": "steward-review-before-scoring", + "protectedReasons": [ + "weekly_hours_exceeded", + "expertise_mismatch" + ], + "allowedCompletionDelta": 0, + "blockedDeclineDelta": -5, + "blockedLateDelta": -8 + }, + { + "assignmentId": "asg-107", + "reviewerId": "rev-003", + "decision": "suppress-negative-reputation-delta", + "protectedReasons": [ + "unavailable", + "opted_out" + ], + "allowedCompletionDelta": 6, + "blockedDeclineDelta": -3, + "blockedLateDelta": -5 + } + ], + "concentration": { + "total": 6, + "entries": [ + { + "reviewerId": "rev-001", + "count": 4, + "ratio": 0.6666666666666666 + }, + { + "reviewerId": "rev-002", + "count": 1, + "ratio": 0.16666666666666666 + }, + { + "reviewerId": "rev-003", + "count": 1, + "ratio": 0.16666666666666666 + } + ] + }, + "safety": [ + "Synthetic reviewer, assignment, availability, and reputation data only", + "No profile writes, leaderboard writes, identity calls, email calls, or external review system calls", + "No private reviewer identities, credentials, moderation records, or live reputation mutations" + ] +} diff --git a/reviewer-workload-equity-guard/reports/summary.svg b/reviewer-workload-equity-guard/reports/summary.svg new file mode 100644 index 00000000..14609bca --- /dev/null +++ b/reviewer-workload-equity-guard/reports/summary.svg @@ -0,0 +1,16 @@ + + + Reviewer Workload Equity Guard + SCIBASE #15 reputation scoring fairness checkpoint + + block-reputation-scoring-until-workload-is-fair + Critical 2 | High 6 | Findings 11 + + Equity score + + + 0/100 + + Suppress unfair negative deltas + Checks capacity, opt-outs, rest windows, availability, expertise, and credit concentration before scoring. + diff --git a/reviewer-workload-equity-guard/requirements-map.md b/reviewer-workload-equity-guard/requirements-map.md new file mode 100644 index 00000000..242ead56 --- /dev/null +++ b/reviewer-workload-equity-guard/requirements-map.md @@ -0,0 +1,18 @@ +# Requirements Map + +Issue: `SCIBASE-AI/SCIBASE.AI#15` + +| Issue requirement | Implementation | +| --- | --- | +| Community and user reputation | Evaluates peer-review assignments before profile reputation, badges, leaderboards, or timeline effects are applied. | +| Fair reputation scoring | Suppresses unfair decline and late penalties when reviewers are overloaded, unavailable, opted out, resting, or assigned outside expertise. | +| Reviewer profile safety | Produces assignment-level decisions that separate allowed completion gains from blocked negative deltas. | +| Community health | Detects concentration of review credit so reputation opportunities are not captured by a small group of reviewers. | +| Non-overlap with existing slices | Focuses on workload equity and unfair penalty suppression, not recusal, timeliness scoring, edit history, profile visibility, civility, or template rubrics. | +| Safe local validation | Includes dependency-free tests and demo generation from synthetic reviewer and assignment metadata only. | + +## Non-goals + +- No live profile, leaderboard, badge, identity, moderation, or email mutations. +- No private reviewer data, credentials, real user records, or external calls. +- No replacement for recusal, calibration, mentorship, appeal, or rubric workflows. diff --git a/reviewer-workload-equity-guard/sample-data.js b/reviewer-workload-equity-guard/sample-data.js new file mode 100644 index 00000000..eaef4078 --- /dev/null +++ b/reviewer-workload-equity-guard/sample-data.js @@ -0,0 +1,98 @@ +const project = { + asOfDate: "2026-05-22", + policy: { + maxOpenReviews: 3, + maxWeeklyReviewHours: 10, + minimumRestDays: 2, + concentrationWindowDays: 14, + maxReviewerConcentrationRatio: 0.35, + acceptedAvailabilityStatuses: ["available", "limited"], + protectedPenaltyReasons: ["over_capacity", "unavailable", "rest_window", "opted_out"] + }, + reviewers: [ + { + id: "rev-001", + displayName: "A. Rowan", + trustTier: "senior", + availabilityStatus: "limited", + weeklyCapacityHours: 8, + recentReviewHours: 9, + openReviewIds: ["asg-101", "asg-102", "asg-103"], + lastCompletedReviewAt: "2026-05-21", + optOutUntil: null, + unavailableWindows: [{ startsAt: "2026-05-23", endsAt: "2026-05-27", reason: "fieldwork" }], + expertise: ["astrobiology", "data-curation"], + profileImpactAllowed: true + }, + { + id: "rev-002", + displayName: "M. Quinn", + trustTier: "early-career", + availabilityStatus: "available", + weeklyCapacityHours: 6, + recentReviewHours: 5, + openReviewIds: ["asg-104"], + lastCompletedReviewAt: "2026-05-16", + optOutUntil: null, + unavailableWindows: [], + expertise: ["statistics", "survey-design"], + profileImpactAllowed: true + }, + { + id: "rev-003", + displayName: "S. Imani", + trustTier: "senior", + availabilityStatus: "unavailable", + weeklyCapacityHours: 10, + recentReviewHours: 1, + openReviewIds: [], + lastCompletedReviewAt: "2026-05-10", + optOutUntil: "2026-05-30", + unavailableWindows: [{ startsAt: "2026-05-20", endsAt: "2026-05-31", reason: "leave" }], + expertise: ["machine-learning", "statistics"], + profileImpactAllowed: true + } + ], + pendingAssignments: [ + { + id: "asg-105", + reviewerId: "rev-001", + topic: "data-curation", + dueDate: "2026-05-24", + estimatedHours: 4, + status: "pending", + reputationDelta: { completion: 8, decline: -4, late: -6 }, + affectsLeaderboard: true + }, + { + id: "asg-106", + reviewerId: "rev-002", + topic: "quantum-simulation", + dueDate: "2026-05-25", + estimatedHours: 5, + status: "pending", + reputationDelta: { completion: 10, decline: -5, late: -8 }, + affectsLeaderboard: true + }, + { + id: "asg-107", + reviewerId: "rev-003", + topic: "machine-learning", + dueDate: "2026-05-26", + estimatedHours: 3, + status: "pending", + reputationDelta: { completion: 6, decline: -3, late: -5 }, + affectsLeaderboard: true + } + ], + recentAssignmentHistory: [ + { id: "hist-001", reviewerId: "rev-001", assignedAt: "2026-05-12", pointsAwarded: 8 }, + { id: "hist-002", reviewerId: "rev-001", assignedAt: "2026-05-13", pointsAwarded: 7 }, + { id: "hist-003", reviewerId: "rev-001", assignedAt: "2026-05-16", pointsAwarded: 8 }, + { id: "hist-004", reviewerId: "rev-001", assignedAt: "2026-05-19", pointsAwarded: 9 }, + { id: "hist-005", reviewerId: "rev-002", assignedAt: "2026-05-17", pointsAwarded: 6 }, + { id: "hist-006", reviewerId: "rev-003", assignedAt: "2026-05-20", pointsAwarded: 5 } + ] +}; + +module.exports = { project }; diff --git a/reviewer-workload-equity-guard/test.js b/reviewer-workload-equity-guard/test.js new file mode 100644 index 00000000..d5d3a090 --- /dev/null +++ b/reviewer-workload-equity-guard/test.js @@ -0,0 +1,106 @@ +const assert = require("assert"); +const { project } = require("./sample-data"); +const { + assignmentRiskReasons, + buildReviewPacket, + evaluateWorkloadEquity, + renderMarkdownReport, + renderSvgSummary +} = require("./index"); + +const evaluation = evaluateWorkloadEquity(project); +const packet = buildReviewPacket(project); + +assert.strictEqual(packet.guard, "reviewer-workload-equity-guard"); +assert.strictEqual(packet.issue, "SCIBASE-AI/SCIBASE.AI#15"); +assert.strictEqual(packet.decision, "block-reputation-scoring-until-workload-is-fair"); + +assert.ok( + evaluation.findings.some((finding) => finding.rule === "open-review-load-exceeded"), + "expected open review load finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "weekly-review-hour-budget-exceeded"), + "expected weekly hour budget finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "reviewer-unavailable-but-penalized"), + "expected unavailable reviewer penalty finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "reviewer-opt-out-active"), + "expected opt-out finding" +); +assert.ok( + evaluation.findings.some((finding) => finding.rule === "review-credit-concentration-too-high"), + "expected concentration finding" +); + +const overloadedReviewer = project.reviewers.find((reviewer) => reviewer.id === "rev-001"); +const overloadedAssignment = project.pendingAssignments.find((assignment) => assignment.id === "asg-105"); +const reasons = assignmentRiskReasons(project, overloadedReviewer, overloadedAssignment); +assert.ok(reasons.includes("over_capacity")); +assert.ok(reasons.includes("weekly_hours_exceeded")); + +const cleanProject = JSON.parse(JSON.stringify(project)); +cleanProject.reviewers.forEach((reviewer) => { + reviewer.availabilityStatus = "available"; + reviewer.weeklyCapacityHours = 14; + reviewer.recentReviewHours = 1; + reviewer.openReviewIds = []; + reviewer.lastCompletedReviewAt = "2026-05-15"; + reviewer.optOutUntil = null; + reviewer.unavailableWindows = []; +}); +cleanProject.pendingAssignments = [ + { + id: "asg-clean-1", + reviewerId: "rev-001", + topic: "data-curation", + dueDate: "2026-05-29", + estimatedHours: 2, + status: "pending", + reputationDelta: { completion: 5, decline: -1, late: -2 }, + affectsLeaderboard: false + }, + { + id: "asg-clean-2", + reviewerId: "rev-002", + topic: "statistics", + dueDate: "2026-05-29", + estimatedHours: 2, + status: "pending", + reputationDelta: { completion: 5, decline: -1, late: -2 }, + affectsLeaderboard: false + }, + { + id: "asg-clean-3", + reviewerId: "rev-003", + topic: "machine-learning", + dueDate: "2026-05-29", + estimatedHours: 2, + status: "pending", + reputationDelta: { completion: 5, decline: -1, late: -2 }, + affectsLeaderboard: false + } +]; +cleanProject.recentAssignmentHistory = [ + { id: "hist-clean-1", reviewerId: "rev-001", assignedAt: "2026-05-18", pointsAwarded: 3 }, + { id: "hist-clean-2", reviewerId: "rev-002", assignedAt: "2026-05-18", pointsAwarded: 3 }, + { id: "hist-clean-3", reviewerId: "rev-003", assignedAt: "2026-05-18", pointsAwarded: 3 } +]; +cleanProject.policy.maxReviewerConcentrationRatio = 0.34; + +const cleanPacket = buildReviewPacket(cleanProject); +assert.strictEqual(cleanPacket.decision, "workload-equity-ready"); +assert.strictEqual(cleanPacket.findings.length, 0); + +const markdown = renderMarkdownReport(packet); +assert.ok(markdown.includes("## Assignment Decisions")); +assert.ok(markdown.includes("suppress-negative-reputation-delta")); + +const svg = renderSvgSummary(packet); +assert.ok(svg.includes("