From 99e3f9f30128da0ce132e3d6df0ca27cc55b0b8c Mon Sep 17 00:00:00 2001 From: taherd <183945978+taherdhanera@users.noreply.github.com> Date: Fri, 22 May 2026 22:56:56 +0530 Subject: [PATCH] Add reviewer workload equity guard --- reviewer-workload-equity-guard/README.md | 36 ++ reviewer-workload-equity-guard/demo-video.js | 173 ++++++++ reviewer-workload-equity-guard/demo.js | 18 + reviewer-workload-equity-guard/index.js | 368 ++++++++++++++++++ reviewer-workload-equity-guard/package.json | 14 + .../reports/demo.webm | Bin 0 -> 52548 bytes .../reports/reviewer-packet.md | 65 ++++ .../reports/summary.json | 191 +++++++++ .../reports/summary.svg | 16 + .../requirements-map.md | 18 + reviewer-workload-equity-guard/sample-data.js | 98 +++++ reviewer-workload-equity-guard/test.js | 106 +++++ 12 files changed, 1103 insertions(+) create mode 100644 reviewer-workload-equity-guard/README.md create mode 100644 reviewer-workload-equity-guard/demo-video.js create mode 100644 reviewer-workload-equity-guard/demo.js create mode 100644 reviewer-workload-equity-guard/index.js create mode 100644 reviewer-workload-equity-guard/package.json create mode 100644 reviewer-workload-equity-guard/reports/demo.webm create mode 100644 reviewer-workload-equity-guard/reports/reviewer-packet.md create mode 100644 reviewer-workload-equity-guard/reports/summary.json create mode 100644 reviewer-workload-equity-guard/reports/summary.svg create mode 100644 reviewer-workload-equity-guard/requirements-map.md create mode 100644 reviewer-workload-equity-guard/sample-data.js create mode 100644 reviewer-workload-equity-guard/test.js 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` + +
+ +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 ` +`; +} + +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 0000000000000000000000000000000000000000..e646fac71bfcd1e4fd5b694f193f5f152a9c0970 GIT binary patch literal 52548 zcmeFYb#NU`vL||un3
s%Gu@jOUlhx
zCyu@DzT;m{ERXY75QU!4q71$dgF=-)RigWk*@*h!Ym76G-Ixk}M_&&}fO~n|ZT-N<
zg98UXMrY51W{4?T)$eEFOS_jirve*%1^%ggnEa+%gzwhMK2D!wl@h^OP=qHFjPslt
z@a6-MRiVk9U|Ry=o1gIf!pT+(I78edtQZ%0w+E)v-b~ov+4M7GuG$L-(T|7+qvVi0
zCXCTE(&w`l9q-J%e3`X)&9SVYu}U|Mbzu)@M9EhA>#o7+7bjRQ_5t4^ms(pICR=cv
z%919H64~S;J+tamqZlP_9@~n?XH|}JK53wvF3S2bV{ r$O^|^GhNCub2fNETDCn(kOAyA9B$n+0
zKFZ8qMaTnXWKvPo-W8XKMJn;(U`VQx$MpLAomXYI!xco4d!a;Lfwr-e#Y=3o_m%xq
zQB-{XWD06*oNEsrVuxk1MzEo@@HG#HE*%QBGe#@yXHhz%1YX?rH9FggA=o9NrIKfN
zsBoSR1K#3Gju$)D3u}uf6ZL~1Oe%@o6fCCkE5TW?LWc|i!8@K?ir?7m+gJu0^14n~
zj>B597S661spEqj^`FbeUM?}=K%^os9vf&!l%1$chM19!J3kMm<8G@}hj8%z6q9RP
z$Zj;mlT>^O4eZr=eJ=r*V6gpt6u3=&X@b+@jane&E`s@tpLv)bl82qz(hKW<&qOt3
zNuU%dtg_2lH(i(x^?dv@R&r8m6+)n5zs-fA5j=)j%?rwbuN#DAu&jl~jjmfYIY-
zl|nOLC4iIrBPjc}H;Smp9M(onXxMfK)b7M4*>#x%vSv*IJ)Ut81|S;plX=>wzr09W
zPFOH6+YybXZ|MU#BbtZtl?8jc+)bTAXq%QZ^PQJz^1RWXOy2Z-7l}h7(Lqhzlk^N%79jhNRbs^8tmHI*k;
zdir^5^eyaXlFSpqFFeyAjXjtLN
@j
z$jR)#`W5e2E1^p3A%V93k*oXcN53rMMA=sW=ko5kmOnE0!
ptZ?_=Il<