From c64251508ccf3c662ff3f57db2ddfe523e47d878 Mon Sep 17 00:00:00 2001 From: Julian Ahlmark Date: Fri, 29 May 2026 15:48:06 +0300 Subject: [PATCH] Add challenge conflict recusal guard --- challenge-recusal-guard/demo.js | 49 ++++++++ challenge-recusal-guard/index.js | 106 ++++++++++++++++++ challenge-recusal-guard/readme.md | 28 +++++ challenge-recusal-guard/render-video.js | 38 +++++++ challenge-recusal-guard/reports/demo.mp4 | Bin 0 -> 7036 bytes .../reports/recusal-report.json | 90 +++++++++++++++ .../reports/recusal-report.md | 12 ++ challenge-recusal-guard/reports/summary.svg | 9 ++ challenge-recusal-guard/sample-data.js | 60 ++++++++++ challenge-recusal-guard/test.js | 25 +++++ 10 files changed, 417 insertions(+) create mode 100644 challenge-recusal-guard/demo.js create mode 100644 challenge-recusal-guard/index.js create mode 100644 challenge-recusal-guard/readme.md create mode 100644 challenge-recusal-guard/render-video.js create mode 100644 challenge-recusal-guard/reports/demo.mp4 create mode 100644 challenge-recusal-guard/reports/recusal-report.json create mode 100644 challenge-recusal-guard/reports/recusal-report.md create mode 100644 challenge-recusal-guard/reports/summary.svg create mode 100644 challenge-recusal-guard/sample-data.js create mode 100644 challenge-recusal-guard/test.js diff --git a/challenge-recusal-guard/demo.js b/challenge-recusal-guard/demo.js new file mode 100644 index 00000000..24d81c0a --- /dev/null +++ b/challenge-recusal-guard/demo.js @@ -0,0 +1,49 @@ +const fs = require("fs"); +const path = require("path"); +const { assignments } = require("./sample-data"); +const { evaluateAssignments } = require("./index"); + +const outDir = path.join(__dirname, "reports"); +fs.mkdirSync(outDir, { recursive: true }); + +const report = evaluateAssignments(assignments); +fs.writeFileSync(path.join(outDir, "recusal-report.json"), JSON.stringify(report, null, 2)); + +const rows = report.reports + .map((entry) => `| ${entry.challengeId} | ${entry.evaluatorId} | ${entry.decision} | ${entry.findings.length} |`) + .join("\n"); + +fs.writeFileSync( + path.join(outDir, "recusal-report.md"), + [ + "# Challenge Conflict-of-Interest Recusal Guard", + "", + "| Challenge | Evaluator | Decision | Findings |", + "| --- | --- | --- | ---: |", + rows, + "", + `Payout holds: ${report.payoutHolds}`, + `Evaluator recusals: ${report.recusals}`, + `Disclosure gates: ${report.disclosures}` + ].join("\n") +); + +fs.writeFileSync( + path.join(outDir, "summary.svg"), + ` + + Challenge Recusal Guard + Synthetic COI checks before scoring and payout decisions + + ${report.payoutHolds} payout holds + + ${report.recusals} recusals +` +); + +console.log(JSON.stringify({ + totalAssignments: report.totalAssignments, + payoutHolds: report.payoutHolds, + recusals: report.recusals, + disclosures: report.disclosures +}, null, 2)); diff --git a/challenge-recusal-guard/index.js b/challenge-recusal-guard/index.js new file mode 100644 index 00000000..46487137 --- /dev/null +++ b/challenge-recusal-guard/index.js @@ -0,0 +1,106 @@ +function evaluateAssignment(assignment) { + const findings = []; + + if (assignment.recentCoauthorshipMonths !== null && assignment.recentCoauthorshipMonths <= 24) { + findings.push({ + type: "recent-coauthorship", + severity: "high", + detail: `Coauthorship ${assignment.recentCoauthorshipMonths} months ago requires recusal or independent review.` + }); + } + + if (assignment.sameInstitution) { + findings.push({ + type: "same-institution", + severity: "medium", + detail: "Evaluator and team share an institution; require disclosure and secondary reviewer." + }); + } + + if (assignment.sponsorEmployment && assignment.evaluatorRole !== "sponsor-observer") { + findings.push({ + type: "sponsor-employment", + severity: "high", + detail: "Sponsor employee cannot be the sole scoring reviewer for payout decisions." + }); + } + + if (assignment.financialInterestUsd >= 10000) { + findings.push({ + type: "material-financial-interest", + severity: "critical", + detail: "Material financial interest requires payout decision hold and alternate reviewer." + }); + } + + if (assignment.competingTeamMembership) { + findings.push({ + type: "competing-team-membership", + severity: "critical", + detail: "Evaluator belongs to a competing team and must be removed from scoring." + }); + } + + if (assignment.anonymityRequired && assignment.identitySharedWithSponsor) { + findings.push({ + type: "anonymity-breach", + severity: "high", + detail: "Anonymous challenge identity was shared with sponsor before scoring lock." + }); + } + + const highestSeverity = severityRank(findings); + return { + challengeId: assignment.challengeId, + evaluatorId: assignment.evaluatorId, + teamId: assignment.teamId, + decision: decisionFor(highestSeverity), + highestSeverity, + findings, + remediation: remediationFor(findings) + }; +} + +function severityRank(findings) { + const order = ["clear", "low", "medium", "high", "critical"]; + return findings.reduce((highest, finding) => ( + order.indexOf(finding.severity) > order.indexOf(highest) ? finding.severity : highest + ), "clear"); +} + +function decisionFor(severity) { + if (severity === "critical") return "hold-payout-decision"; + if (severity === "high") return "recuse-evaluator"; + if (severity === "medium") return "require-disclosure"; + return "allow-scoring"; +} + +function remediationFor(findings) { + if (findings.length === 0) return ["Record no-conflict attestation before scoring lock."]; + return findings.map((finding) => { + const actions = { + "recent-coauthorship": "Assign an independent reviewer and record coauthorship disclosure.", + "same-institution": "Add a secondary external reviewer before sponsor-visible scoring.", + "sponsor-employment": "Limit sponsor employee to observer notes or require independent scoring.", + "material-financial-interest": "Hold payout decision until an alternate evaluator signs off.", + "competing-team-membership": "Remove evaluator from the panel and quarantine prior scores.", + "anonymity-breach": "Freeze sponsor access, rotate reviewer packet, and log breach review." + }; + return actions[finding.type] || "Route to bounty administrator."; + }); +} + +function evaluateAssignments(assignments) { + const reports = assignments.map(evaluateAssignment); + return { + generatedAt: "2026-05-29T00:00:00.000Z", + totalAssignments: reports.length, + payoutHolds: reports.filter((report) => report.decision === "hold-payout-decision").length, + recusals: reports.filter((report) => report.decision === "recuse-evaluator").length, + disclosures: reports.filter((report) => report.decision === "require-disclosure").length, + allowed: reports.filter((report) => report.decision === "allow-scoring").length, + reports + }; +} + +module.exports = { decisionFor, evaluateAssignment, evaluateAssignments, severityRank }; diff --git a/challenge-recusal-guard/readme.md b/challenge-recusal-guard/readme.md new file mode 100644 index 00000000..12b5167a --- /dev/null +++ b/challenge-recusal-guard/readme.md @@ -0,0 +1,28 @@ +# Challenge Conflict-of-Interest Recusal Guard + +This module is a focused Scientific Bounty System slice for checking evaluator, sponsor, and institutional conflicts before challenge scoring or payout decisions. + +It is intentionally separate from payout routing, team split ledgers, arbitration, review integrity, reviewer workload, challenge fairness, deadline fairness, evidence freeze, benchmark leakage, submission quarantine, package security, license risk, sponsor data-room access, and award transparency modules. + +## What it checks + +- Recent coauthorship between evaluator and submitter team +- Shared institutional affiliation requiring disclosure +- Sponsor employee involvement in payout scoring +- Material financial interest +- Competing-team evaluator membership +- Anonymous challenge identity exposure before scoring lock + +## Run + +```bash +node challenge-recusal-guard/test.js +node challenge-recusal-guard/demo.js +node challenge-recusal-guard/render-video.js +``` + +Outputs are written to `challenge-recusal-guard/reports/`. + +## Safety + +All data is synthetic. The module does not call payment providers, sponsors, reviewers, bounty platforms, private submissions, identity systems, credentials, or production SCIBASE services. diff --git a/challenge-recusal-guard/render-video.js b/challenge-recusal-guard/render-video.js new file mode 100644 index 00000000..8adcfa3c --- /dev/null +++ b/challenge-recusal-guard/render-video.js @@ -0,0 +1,38 @@ +const { execFileSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const outDir = path.join(__dirname, "reports"); +fs.mkdirSync(outDir, { recursive: true }); + +const ppm = path.join(outDir, "demo-frame.ppm"); +const mp4 = path.join(outDir, "demo.mp4"); +const width = 960; +const height = 540; +let body = ""; + +for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const panel = x > 90 && x < 870 && y > 80 && y < 460; + const holdBar = x > 160 && x < 460 && y > 210 && y < 252; + const recuseBar = x > 160 && x < 310 && y > 302 && y < 344; + const r = panel ? (holdBar ? 196 : recuseBar ? 228 : 28) : 10; + const g = panel ? (holdBar ? 63 : recuseBar ? 138 : 32) : 12; + const b = panel ? (holdBar ? 77 : recuseBar ? 42 : 46) : 18; + body += String.fromCharCode(r, g, b); + } +} + +fs.writeFileSync(ppm, `P6\n${width} ${height}\n255\n${body}`, "binary"); +execFileSync("ffmpeg", [ + "-y", + "-loop", "1", + "-framerate", "24", + "-i", ppm, + "-t", "5", + "-vf", "format=yuv420p", + "-movflags", "+faststart", + mp4 +], { stdio: "inherit" }); + +console.log(mp4); diff --git a/challenge-recusal-guard/reports/demo.mp4 b/challenge-recusal-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f9c3a96e75799ac36de778eba8a5512bd976f9d2 GIT binary patch literal 7036 zcmeHKeN+_J6`yrcz;aX&0}AK_DK!FP6 zNd*IbCefw|r-caiR3jK#IU1yj9}tx$CQ*;arb0OeGSRVNalbrOU`j&s>dn1tpV=DG682Y zTy-;e8R_a6!_cw>R3yVnvfC6`Kz9AyWAQOeGu}$GqT2?R zZ=;z}8r2)1&95fyG|M0z|2dq^ZUG`RpK}=c6PX~T!qcB_T2m=x_NkL2z3rM%c6Ty zqiS5%Ff3e>T@#L)XnYP7a8N?Q53~@dAU?AC5jCb;pzvT(T(X(&dbEw2sE^AQ)IPZ~ zAHMlf<^hF=FFyWz`y&9&H*7}NevE{@O(6yp_ufYP9?`0MAbZ6nQ9i(*o(Nk$HH=bf@$Ej*RY8 zx7$`Xmd9Pg+ZOlUMd=TZ$0=T9aGfC;7Zde*J#J1+HfWPc5UkdKLc%laGgieX;l>T= zph+_n2-10>kYy|~uGeZ4@p*=Bq#yj%;t$?p^4%-fs~<5!5qPb z&}Ih{peT|^$0Cv(Bf)H$O+q2iHkyFNGLbYXAUDh=vyGGxh)rZ%jukMrY^!Vr880w) zQ#LPvRACCxHHRq#+k`$@>n2TbO(XP4xI{5_hRTzPBrO`INTN9v8OaLWMauM@yGSll z1SMK<4sJgTatj)SK2f8^bCENIR$~BCK!#m4=^zY`14EJ-fk?t_0<0251S}%hB6A@C z&~jqM5VA$g_EPK#=g7_XheF#LE{E5DUVLkLr>3X!bVXQ{v-$3Ob?7eUer7&v8CYe1 zIjZ7y7iUee-`lF0SmyeFm9`JeGZWbM$(gkUoERxnb={&i(-1o{nNn^;S)5XKY33z8&|z^jn&r zmPMQnRHv6@ZaJ#zd@ttXlHH;AzG$djES&FE$2G;`x{lkytL7b9yWX_7Onv>%sr=2& zXSWo!zjL}~`<)jff-<(WHrJgC-uc;JB8R1;lc$%?$b{$uj`Iox+C_}O$UDTpQU}XhAKSI2 zrUnatwxoZSV}bwef#n4sWWK7Zc;Z^Uq0Btn67gAE%bRUs-^{)h5*vQ#>hVo=7q4h~ zyMC#9>`Uwi_iEPq6{mi5)&GHA^?Fn1nk86BX3z)fRNJ zF;=Z`l)>M^Xct77TaZLmManDAJS=&wSdmbhJR}Hek+H(Mrp9Rf{knhxrzfBhRKkr_ zTQh>=61+ej@PtH$VK4_hA&V8rI)G%oK&F8TKwik0=M}Qs6A~E)$R4kdeN#XxURKZY z4p|-V38{Elo!||!GHD7(#mnliEs@})0dddVae|HFGM?;JT;`tVhMe+lF_xNd!~_t=GF`7+x%QZ?rx$Z1;?#P9VQ o3y1~o+t!QDkrc>P + + Challenge Recusal Guard + Synthetic COI checks before scoring and payout decisions + + 2 payout holds + + 1 recusals + \ No newline at end of file diff --git a/challenge-recusal-guard/sample-data.js b/challenge-recusal-guard/sample-data.js new file mode 100644 index 00000000..1b0218e9 --- /dev/null +++ b/challenge-recusal-guard/sample-data.js @@ -0,0 +1,60 @@ +const assignments = [ + { + challengeId: "rna-biomarker-prize", + evaluatorId: "reviewer-17", + evaluatorRole: "external-reviewer", + teamId: "team-alpha", + sponsorId: "pharma-sponsor-a", + sameInstitution: false, + recentCoauthorshipMonths: 8, + sponsorEmployment: false, + financialInterestUsd: 0, + competingTeamMembership: false, + anonymityRequired: true, + identitySharedWithSponsor: false + }, + { + challengeId: "materials-catalyst-model", + evaluatorId: "sponsor-scientist-4", + evaluatorRole: "sponsor-reviewer", + teamId: "team-boron", + sponsorId: "materials-sponsor-b", + sameInstitution: false, + recentCoauthorshipMonths: null, + sponsorEmployment: true, + financialInterestUsd: 25000, + competingTeamMembership: false, + anonymityRequired: false, + identitySharedWithSponsor: true + }, + { + challengeId: "climate-forecasting-open", + evaluatorId: "faculty-panel-2", + evaluatorRole: "institutional-reviewer", + teamId: "team-delta", + sponsorId: "climate-nonprofit-c", + sameInstitution: true, + recentCoauthorshipMonths: 42, + sponsorEmployment: false, + financialInterestUsd: 0, + competingTeamMembership: false, + anonymityRequired: false, + identitySharedWithSponsor: false + }, + { + challengeId: "quantum-noise-reduction", + evaluatorId: "solver-advisor-9", + evaluatorRole: "technical-reviewer", + teamId: "team-qubit", + sponsorId: "quantum-startup-d", + sameInstitution: false, + recentCoauthorshipMonths: null, + sponsorEmployment: false, + financialInterestUsd: 0, + competingTeamMembership: true, + anonymityRequired: true, + identitySharedWithSponsor: true + } +]; + +module.exports = { assignments }; diff --git a/challenge-recusal-guard/test.js b/challenge-recusal-guard/test.js new file mode 100644 index 00000000..a1ba4c87 --- /dev/null +++ b/challenge-recusal-guard/test.js @@ -0,0 +1,25 @@ +const assert = require("assert"); +const { assignments } = require("./sample-data"); +const { decisionFor, evaluateAssignments, severityRank } = require("./index"); + +assert.strictEqual(decisionFor("clear"), "allow-scoring"); +assert.strictEqual(decisionFor("medium"), "require-disclosure"); +assert.strictEqual(decisionFor("high"), "recuse-evaluator"); +assert.strictEqual(decisionFor("critical"), "hold-payout-decision"); +assert.strictEqual(severityRank([{ severity: "medium" }, { severity: "critical" }]), "critical"); + +const report = evaluateAssignments(assignments); +assert.strictEqual(report.totalAssignments, 4); +assert.strictEqual(report.payoutHolds, 2); +assert.strictEqual(report.recusals, 1); +assert.strictEqual(report.disclosures, 1); +assert.strictEqual(report.allowed, 0); + +const quantum = report.reports.find((entry) => entry.challengeId === "quantum-noise-reduction"); +assert(quantum.findings.some((finding) => finding.type === "competing-team-membership")); +assert(quantum.findings.some((finding) => finding.type === "anonymity-breach")); + +const sponsor = report.reports.find((entry) => entry.challengeId === "materials-catalyst-model"); +assert(sponsor.findings.some((finding) => finding.type === "material-financial-interest")); + +console.log("challenge-recusal-guard tests passed");