diff --git a/scientific-bounty-risk-controls/README.md b/scientific-bounty-risk-controls/README.md new file mode 100644 index 0000000..2af8e7b --- /dev/null +++ b/scientific-bounty-risk-controls/README.md @@ -0,0 +1,42 @@ +# Scientific Bounty Risk Controls + +Self-contained contribution for SCIBASE issue #18, focused on anti-collusion, duplicate-submission detection, reviewer/sponsor conflict checks, and payout hold decisions for a scientific bounty marketplace. + +This module helps a bounty operator answer: + +- Are two solver teams submitting the same artifact or near-duplicate method package? +- Are assigned reviewers or arbitrators conflicted with solver teams or the sponsor? +- Do timing and anonymized network signals suggest coordination that should be spot-checked? +- Which submissions are release-ready, which need manual review, and which should be held for arbitration before prize payout or IP handoff? + +It is deterministic, dependency-free, credential-free, and uses synthetic sample data only. + +## Run + +```bash +cd scientific-bounty-risk-controls +npm run check +npm test +npm run demo +``` + +Demo recording: `docs/bounty-risk-demo.mp4` + +## What It Includes + +- `src/bounty-risk-controls.js` - duplicate-submission checks, reviewer/sponsor conflict checks, timing/network signals, payout decisions, arbitration queue, and audit digest logic. +- `sample-data.json` - synthetic challenge, solver teams, submissions, artifacts, reviewers, and review assignments. +- `test.js` - Node assertion tests for text similarity, shared artifact hashes, timing/network signals, conflicted reviewers, payout holds, audit digest changes, and validation errors. +- `demo.js` - CLI demo that prints a reviewer-ready risk packet. +- `docs/requirement-map.md` - direct mapping to issue #18 requirements. + +## Design Notes + +This is intentionally not another broad bounty-system or marketplace mock. It focuses on the trust layer that should run before scientific prize payout: + +- Shared artifact hashes produce high-severity duplicate findings. +- Similar methods and deliverable manifests produce manual-review signals. +- Reviewer and arbitrator assignments are checked against institutions, prior collaborations, sponsor employment, and sponsor financial relationships. +- Anonymized network reuse and short submission bursts are treated as review signals, not automatic fraud findings. +- Payout decisions preserve solver fairness by separating `release-ready`, `manual-review`, and `hold-for-arbitration` states. +- The audit digest lets future backend work persist and compare risk packets without storing private raw evidence in public logs. diff --git a/scientific-bounty-risk-controls/demo.js b/scientific-bounty-risk-controls/demo.js new file mode 100644 index 0000000..0794813 --- /dev/null +++ b/scientific-bounty-risk-controls/demo.js @@ -0,0 +1,45 @@ +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); +const { analyzeScientificBountyRisk } = require("./src/bounty-risk-controls"); + +const samplePath = path.join(__dirname, "sample-data.json"); +const sample = JSON.parse(fs.readFileSync(samplePath, "utf8")); +const report = analyzeScientificBountyRisk(sample); + +const demoSummary = { + challengeId: report.challengeId, + sponsorId: report.sponsorId, + summary: report.summary, + duplicateFindings: report.duplicateFindings.map((finding) => ({ + code: finding.code, + severity: finding.severity, + submissionIds: finding.submissionIds, + weightedSimilarity: finding.weightedSimilarity, + sharedArtifactHashes: finding.sharedArtifactHashes, + })), + conflictFindings: report.conflictFindings.map((finding) => ({ + code: finding.code, + severity: finding.severity, + submissionId: finding.submissionId, + reviewerId: finding.reviewerId, + summary: finding.summary, + })), + timingFindings: report.timingFindings.map((finding) => ({ + code: finding.code, + severity: finding.severity, + submissionIds: finding.submissionIds, + teamIds: finding.teamIds, + })), + payoutDecisions: report.payoutDecisions, + arbitrationQueue: report.arbitrationQueue.map((decision) => ({ + submissionId: decision.submissionId, + teamId: decision.teamId, + payoutStatus: decision.payoutStatus, + riskScore: decision.riskScore, + })), + auditDigest: report.auditDigest, +}; + +console.log(JSON.stringify(demoSummary, null, 2)); diff --git a/scientific-bounty-risk-controls/docs/bounty-risk-demo.mp4 b/scientific-bounty-risk-controls/docs/bounty-risk-demo.mp4 new file mode 100644 index 0000000..ac8c8ea Binary files /dev/null and b/scientific-bounty-risk-controls/docs/bounty-risk-demo.mp4 differ diff --git a/scientific-bounty-risk-controls/docs/requirement-map.md b/scientific-bounty-risk-controls/docs/requirement-map.md new file mode 100644 index 0000000..1d14a99 --- /dev/null +++ b/scientific-bounty-risk-controls/docs/requirement-map.md @@ -0,0 +1,24 @@ +# Requirement Map for SCIBASE Issue #18 + +Issue #18 asks for a Scientific Bounty System with challenge posting, secure submissions, arbitration, reward distribution, payout routing, IP controls, and trust on both sides of the marketplace. + +This contribution implements a focused risk-control slice that can sit between submission evaluation and payout. + +| Issue requirement | Implemented here | +| --- | --- | +| Challenge posting portal with evaluation criteria | `sample-data.json` models a challenge with sponsor, prize pool, payout policy, and IP policy metadata. | +| Submission engine with standardized deliverables | Submissions include titles, abstracts, methods, deliverable manifests, timestamps, anonymized network IDs, and artifact hashes. | +| Submission package validation | `compareSubmissions` checks shared artifact hashes and near-duplicate method/deliverable text before payout. | +| Arbitration and third-party review | `evaluateAssignments` checks reviewer and arbitrator conflicts before their review can drive payout decisions. | +| Reward distribution and payout routing | `payoutDecisionForSubmission` returns `release-ready`, `manual-review`, or `hold-for-arbitration` decisions with reasons. | +| IP management options | The sample challenge carries `ipPolicy`, and payout holds prevent unsafe release before conflicts or duplicates are resolved. | +| Trust on both sides | Findings separate high-severity conflicts from softer timing/network signals so sponsors, solvers, and reviewers get auditable decisions. | +| Reviewer-ready evidence | `analyzeScientificBountyRisk` emits duplicate findings, conflict findings, timing findings, payout decisions, arbitration queue, content fingerprints, and an audit digest. | +| Local verification | `npm run check`, `npm test`, and `npm run demo` run without credentials, services, or external dependencies. | + +## Non-goals + +- No real identity, payment, or wallet data is processed. +- No live sponsor, reviewer, or solver accounts are required. +- No external fraud-scoring service or ML model is added. +- No private IP or undisclosed research data is committed. diff --git a/scientific-bounty-risk-controls/package.json b/scientific-bounty-risk-controls/package.json new file mode 100644 index 0000000..aff6e78 --- /dev/null +++ b/scientific-bounty-risk-controls/package.json @@ -0,0 +1,13 @@ +{ + "name": "scientific-bounty-risk-controls", + "version": "1.0.0", + "description": "Dependency-free anti-collusion and payout hold controls for SCIBASE scientific bounty submissions.", + "main": "src/bounty-risk-controls.js", + "scripts": { + "check": "node --check src/bounty-risk-controls.js && node --check demo.js && node --check test.js", + "demo": "node demo.js", + "test": "node test.js" + }, + "license": "MIT", + "private": true +} diff --git a/scientific-bounty-risk-controls/sample-data.json b/scientific-bounty-risk-controls/sample-data.json new file mode 100644 index 0000000..073aad2 --- /dev/null +++ b/scientific-bounty-risk-controls/sample-data.json @@ -0,0 +1,203 @@ +{ + "generatedAt": "2026-05-15T07:45:00.000Z", + "challenge": { + "id": "challenge-biomarker-2026", + "title": "Identify reproducible inflammatory biomarkers from single-cell RNA-seq", + "sponsorId": "sponsor-neuro-lab", + "prizePoolUsd": 100000, + "payoutPolicy": "milestone-and-final-award", + "ipPolicy": "solver-retains-until-paid" + }, + "teams": [ + { + "id": "team-atlas", + "name": "Atlas Immunology", + "members": [ + { + "id": "solver-chen", + "name": "Mina Chen", + "institution": "Northbridge University" + }, + { + "id": "solver-owens", + "name": "Tariq Owens", + "institution": "Northbridge University" + } + ] + }, + { + "id": "team-lattice", + "name": "Lattice Bio", + "members": [ + { + "id": "solver-varga", + "name": "Elena Varga", + "institution": "River City Institute" + } + ] + }, + { + "id": "team-mirror", + "name": "Mirror Methods", + "members": [ + { + "id": "solver-ramos", + "name": "Iris Ramos", + "institution": "Northbridge University" + } + ] + }, + { + "id": "team-cascade", + "name": "Cascade Genomics", + "members": [ + { + "id": "solver-patel", + "name": "Dev Patel", + "institution": "West Harbor Lab" + } + ] + } + ], + "submissions": [ + { + "id": "sub-atlas", + "teamId": "team-atlas", + "title": "Microglia inflammatory biomarker atlas with reproducible notebooks", + "abstract": "A single-cell RNA-seq workflow identifies microglia markers with confidence intervals and reproducible notebook evidence.", + "methodsSummary": "Normalize single-cell RNA sequencing counts, cluster microglia populations, test inflammatory marker enrichment, and validate biomarkers against held-out cohorts.", + "deliverableManifest": "Notebook pipeline, biomarker table, confidence interval report, reproducibility container, and summary manuscript.", + "submittedAt": "2026-05-15T07:02:00.000Z", + "anonymizedNetworkId": "anon-net-17", + "artifacts": [ + { + "name": "analysis.ipynb", + "hash": "sha256:atlas-notebook-001" + }, + { + "name": "biomarkers.csv", + "hash": "sha256:atlas-biomarkers-001" + } + ] + }, + { + "id": "sub-lattice", + "teamId": "team-lattice", + "title": "Independent cytokine response model for biomarker nomination", + "abstract": "A graph-guided model ranks cytokine response markers and validates findings against public tissue atlases.", + "methodsSummary": "Build a graph model over cytokine pathways, rank candidate biomarkers, test specificity across tissue atlases, and report uncertainty for each marker.", + "deliverableManifest": "Graph model code, ranked biomarker JSON, validation summary, uncertainty report, and reproducibility notes.", + "submittedAt": "2026-05-15T07:08:00.000Z", + "anonymizedNetworkId": "anon-net-41", + "artifacts": [ + { + "name": "model.js", + "hash": "sha256:lattice-model-201" + }, + { + "name": "ranked-biomarkers.json", + "hash": "sha256:lattice-ranked-201" + } + ] + }, + { + "id": "sub-mirror", + "teamId": "team-mirror", + "title": "Microglia inflammatory biomarker atlas with reproducible notebooks", + "abstract": "A reproducible single-cell workflow identifies inflammatory microglia markers with confidence intervals and validation cohorts.", + "methodsSummary": "Normalize single-cell RNA sequencing counts, cluster microglia populations, test inflammatory marker enrichment, and validate biomarkers against held-out cohorts.", + "deliverableManifest": "Notebook pipeline, biomarker table, confidence interval report, reproducibility container, and summary manuscript.", + "submittedAt": "2026-05-15T07:10:00.000Z", + "anonymizedNetworkId": "anon-net-17", + "artifacts": [ + { + "name": "analysis-copy.ipynb", + "hash": "sha256:atlas-notebook-001" + }, + { + "name": "biomarkers.csv", + "hash": "sha256:mirror-biomarkers-002" + } + ] + }, + { + "id": "sub-cascade", + "teamId": "team-cascade", + "title": "Longitudinal biomarker stability screen for inflammatory disease", + "abstract": "A longitudinal validation workflow checks whether nominated markers remain stable across disease stages and instruments.", + "methodsSummary": "Estimate biomarker stability across longitudinal cohorts, compare instrument batches, and build a reproducibility score for each marker.", + "deliverableManifest": "Stability scoring script, batch comparison report, reproducibility scorecard, and reviewer evidence packet.", + "submittedAt": "2026-05-15T07:16:00.000Z", + "anonymizedNetworkId": "anon-net-17", + "artifacts": [ + { + "name": "stability.js", + "hash": "sha256:cascade-stability-503" + } + ] + } + ], + "reviewers": [ + { + "id": "reviewer-hale", + "name": "Dr. Nora Hale", + "institutions": [ + "Northbridge University" + ], + "priorCollaboratorIds": [ + "solver-chen" + ], + "financialSponsorIds": [], + "sponsorEmployee": false + }, + { + "id": "reviewer-singh", + "name": "Dr. Asha Singh", + "institutions": [ + "Independent Review Cooperative" + ], + "priorCollaboratorIds": [], + "financialSponsorIds": [], + "sponsorEmployee": false + }, + { + "id": "reviewer-miles", + "name": "Dr. Owen Miles", + "institutions": [ + "Sponsor Research Group" + ], + "priorCollaboratorIds": [], + "financialSponsorIds": [ + "sponsor-neuro-lab" + ], + "sponsorEmployee": true, + "employerId": "sponsor-neuro-lab" + } + ], + "assignments": [ + { + "id": "assign-1", + "submissionId": "sub-atlas", + "reviewerId": "reviewer-hale", + "role": "scientific-reviewer" + }, + { + "id": "assign-2", + "submissionId": "sub-lattice", + "reviewerId": "reviewer-singh", + "role": "scientific-reviewer" + }, + { + "id": "assign-3", + "submissionId": "sub-mirror", + "reviewerId": "reviewer-miles", + "role": "arbitrator" + }, + { + "id": "assign-4", + "submissionId": "sub-cascade", + "reviewerId": "reviewer-singh", + "role": "scientific-reviewer" + } + ] +} diff --git a/scientific-bounty-risk-controls/src/bounty-risk-controls.js b/scientific-bounty-risk-controls/src/bounty-risk-controls.js new file mode 100644 index 0000000..5445538 --- /dev/null +++ b/scientific-bounty-risk-controls/src/bounty-risk-controls.js @@ -0,0 +1,417 @@ +"use strict"; + +const crypto = require("node:crypto"); + +const REQUIRED_FIELDS = ["challenge", "teams", "submissions", "reviewers", "assignments"]; +const DEFAULTS = { + duplicateSimilarityThreshold: 0.72, + suspiciousNetworkReuseThreshold: 2, + burstWindowMinutes: 20, + burstSubmissionThreshold: 3, +}; + +function assertBundle(bundle) { + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { + throw new TypeError("risk-control bundle must be an object"); + } + + for (const field of REQUIRED_FIELDS) { + if (!Array.isArray(bundle[field]) && field !== "challenge") { + throw new TypeError(`missing required bundle array: ${field}`); + } + if (field === "challenge" && (!bundle.challenge || typeof bundle.challenge !== "object")) { + throw new TypeError("missing required bundle field: challenge"); + } + } +} + +function normalizeText(value) { + return String(value || "") + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function tokenSet(value) { + const tokens = normalizeText(value) + .split(" ") + .map((token) => (token.length > 4 && token.endsWith("s") ? token.slice(0, -1) : token)) + .filter((token) => token.length > 2); + return new Set(tokens); +} + +function jaccardSimilarity(left, right) { + const leftSet = tokenSet(left); + const rightSet = tokenSet(right); + const all = new Set([...leftSet, ...rightSet]); + if (all.size === 0) return 0; + + let intersection = 0; + for (const token of leftSet) { + if (rightSet.has(token)) intersection += 1; + } + return Number((intersection / all.size).toFixed(4)); +} + +function stableHash(value) { + return crypto + .createHash("sha256") + .update(stableStringify(value)) + .digest("hex"); +} + +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 contentFingerprint(submission) { + const artifactHashes = (submission.artifacts || []).map((artifact) => artifact.hash).filter(Boolean).sort(); + const text = normalizeText([ + submission.title, + submission.abstract, + submission.methodsSummary, + submission.deliverableManifest, + ].join(" ")); + + return stableHash({ + artifactHashes, + text, + }); +} + +function buildTeamIndex(teams) { + const index = new Map(); + for (const team of teams) { + index.set(team.id, team); + } + return index; +} + +function buildReviewerIndex(reviewers) { + const index = new Map(); + for (const reviewer of reviewers) { + index.set(reviewer.id, reviewer); + } + return index; +} + +function compareSubmissions(submissions, options = {}) { + const threshold = options.duplicateSimilarityThreshold || DEFAULTS.duplicateSimilarityThreshold; + const findings = []; + + for (let i = 0; i < submissions.length; i += 1) { + for (let j = i + 1; j < submissions.length; j += 1) { + const left = submissions[i]; + const right = submissions[j]; + const sharedArtifactHashes = sharedHashes(left.artifacts || [], right.artifacts || []); + const methodSimilarity = jaccardSimilarity(left.methodsSummary, right.methodsSummary); + const manifestSimilarity = jaccardSimilarity(left.deliverableManifest, right.deliverableManifest); + const titleSimilarity = jaccardSimilarity(left.title, right.title); + const weightedSimilarity = Number( + ((methodSimilarity * 0.5) + (manifestSimilarity * 0.35) + (titleSimilarity * 0.15)).toFixed(4) + ); + + if (sharedArtifactHashes.length > 0 || weightedSimilarity >= threshold) { + findings.push({ + code: sharedArtifactHashes.length > 0 ? "shared-artifact-hash" : "high-text-similarity", + severity: sharedArtifactHashes.length > 0 ? "high" : "medium", + submissionIds: [left.id, right.id], + teamIds: [left.teamId, right.teamId], + weightedSimilarity, + sharedArtifactHashes, + summary: sharedArtifactHashes.length > 0 + ? "Submissions reuse one or more artifact hashes and require duplicate-work review." + : "Submissions have unusually similar title, method, and deliverable text.", + }); + } + } + } + + return findings; +} + +function sharedHashes(leftArtifacts, rightArtifacts) { + const right = new Set(rightArtifacts.map((artifact) => artifact.hash).filter(Boolean)); + return leftArtifacts + .map((artifact) => artifact.hash) + .filter((hash) => hash && right.has(hash)); +} + +function detectRelationshipConflicts(challenge, team, reviewer, assignment) { + const reasons = []; + const members = team.members || []; + const memberInstitutions = new Set(members.map((member) => member.institution).filter(Boolean)); + const memberIds = new Set(members.map((member) => member.id)); + const reviewerCollaborators = new Set(reviewer.priorCollaboratorIds || []); + const reviewerFinancialSponsors = new Set(reviewer.financialSponsorIds || []); + const reviewerInstitutions = new Set(reviewer.institutions || []); + + if (reviewer.sponsorEmployee === true || reviewer.employerId === challenge.sponsorId) { + reasons.push({ + code: "sponsor-employee-reviewer", + severity: "high", + summary: "Assigned reviewer is employed by the challenge sponsor.", + }); + } + + for (const institution of memberInstitutions) { + if (reviewerInstitutions.has(institution)) { + reasons.push({ + code: "shared-institution", + severity: "medium", + summary: `Reviewer shares institution ${institution} with a solver team member.`, + }); + } + } + + for (const memberId of memberIds) { + if (reviewerCollaborators.has(memberId)) { + reasons.push({ + code: "recent-collaboration", + severity: "high", + summary: "Reviewer has a recent collaboration with a solver team member.", + }); + } + } + + if (reviewerFinancialSponsors.has(challenge.sponsorId)) { + reasons.push({ + code: "sponsor-financial-interest", + severity: "high", + summary: "Reviewer has a financial relationship with the challenge sponsor.", + }); + } + + if (assignment.role === "arbitrator" && reasons.some((reason) => reason.severity === "high")) { + reasons.push({ + code: "arbitrator-hard-block", + severity: "high", + summary: "High-severity conflicts block arbitrator assignment.", + }); + } + + return reasons; +} + +function evaluateAssignments(bundle) { + const teamIndex = buildTeamIndex(bundle.teams); + const reviewerIndex = buildReviewerIndex(bundle.reviewers); + const submissionIndex = new Map(bundle.submissions.map((submission) => [submission.id, submission])); + const findings = []; + + for (const assignment of bundle.assignments) { + const submission = submissionIndex.get(assignment.submissionId); + const reviewer = reviewerIndex.get(assignment.reviewerId); + if (!submission || !reviewer) { + findings.push({ + code: "assignment-reference-missing", + severity: "high", + assignment, + summary: "Assignment references a missing submission or reviewer.", + }); + continue; + } + + const team = teamIndex.get(submission.teamId); + if (!team) { + findings.push({ + code: "assignment-team-missing", + severity: "high", + submissionId: submission.id, + teamId: submission.teamId, + summary: "Submission references a missing solver team.", + }); + continue; + } + + const reasons = detectRelationshipConflicts(bundle.challenge, team, reviewer, assignment); + for (const reason of reasons) { + findings.push({ + ...reason, + assignmentId: assignment.id, + submissionId: submission.id, + teamId: team.id, + reviewerId: reviewer.id, + }); + } + } + + return findings; +} + +function detectTimingAndNetworkSignals(submissions, options = {}) { + const networkThreshold = options.suspiciousNetworkReuseThreshold || DEFAULTS.suspiciousNetworkReuseThreshold; + const burstWindowMinutes = options.burstWindowMinutes || DEFAULTS.burstWindowMinutes; + const burstSubmissionThreshold = options.burstSubmissionThreshold || DEFAULTS.burstSubmissionThreshold; + const findings = []; + const byNetwork = new Map(); + + for (const submission of submissions) { + if (!submission.anonymizedNetworkId) continue; + const items = byNetwork.get(submission.anonymizedNetworkId) || []; + items.push(submission); + byNetwork.set(submission.anonymizedNetworkId, items); + } + + for (const [networkId, items] of byNetwork.entries()) { + const teamIds = new Set(items.map((item) => item.teamId)); + if (teamIds.size >= networkThreshold) { + findings.push({ + code: "shared-anonymized-network", + severity: "medium", + networkId, + submissionIds: items.map((item) => item.id), + teamIds: [...teamIds], + summary: "Multiple solver teams submitted from the same anonymized network fingerprint.", + }); + } + } + + const sorted = [...submissions] + .filter((submission) => submission.submittedAt) + .sort((left, right) => new Date(left.submittedAt) - new Date(right.submittedAt)); + + for (let start = 0; start < sorted.length; start += 1) { + const windowStart = new Date(sorted[start].submittedAt).getTime(); + const burst = []; + for (let end = start; end < sorted.length; end += 1) { + const elapsedMinutes = (new Date(sorted[end].submittedAt).getTime() - windowStart) / 60000; + if (elapsedMinutes > burstWindowMinutes) break; + burst.push(sorted[end]); + } + const teams = new Set(burst.map((submission) => submission.teamId)); + if (burst.length >= burstSubmissionThreshold && teams.size >= burstSubmissionThreshold) { + findings.push({ + code: "submission-burst", + severity: "low", + windowMinutes: burstWindowMinutes, + submissionIds: burst.map((submission) => submission.id), + teamIds: [...teams], + summary: "Several independent teams submitted inside a short window and should be spot-checked for coordination.", + }); + break; + } + } + + return findings; +} + +function riskScoreForSubmission(submission, findings) { + const relevant = findings.filter((finding) => + finding.submissionId === submission.id || (finding.submissionIds || []).includes(submission.id) + ); + const score = relevant.reduce((total, finding) => { + if (finding.severity === "high") return total + 45; + if (finding.severity === "medium") return total + 25; + return total + 10; + }, 0); + + return Math.min(100, score); +} + +function payoutDecisionForSubmission(submission, findings) { + const score = riskScoreForSubmission(submission, findings); + const relevant = findings.filter((finding) => + finding.submissionId === submission.id || (finding.submissionIds || []).includes(submission.id) + ); + const highSeverity = relevant.some((finding) => finding.severity === "high"); + const duplicate = relevant.some((finding) => + finding.code === "shared-artifact-hash" || finding.code === "high-text-similarity" + ); + + let status = "release-ready"; + if (highSeverity || score >= 70) { + status = "hold-for-arbitration"; + } else if (duplicate || score >= 35) { + status = "manual-review"; + } + + return { + submissionId: submission.id, + teamId: submission.teamId, + payoutStatus: status, + riskScore: score, + holdReasons: relevant.map((finding) => ({ + code: finding.code, + severity: finding.severity, + summary: finding.summary, + })), + }; +} + +function analyzeScientificBountyRisk(bundle, options = {}) { + assertBundle(bundle); + + const normalizedOptions = { + ...DEFAULTS, + ...options, + }; + const duplicateFindings = compareSubmissions(bundle.submissions, normalizedOptions); + const assignmentFindings = evaluateAssignments(bundle); + const timingFindings = detectTimingAndNetworkSignals(bundle.submissions, normalizedOptions); + const findings = [...duplicateFindings, ...assignmentFindings, ...timingFindings]; + const payoutDecisions = bundle.submissions.map((submission) => payoutDecisionForSubmission(submission, findings)); + const arbitrationQueue = payoutDecisions + .filter((decision) => decision.payoutStatus !== "release-ready") + .sort((left, right) => right.riskScore - left.riskScore); + + const submissionFingerprints = bundle.submissions.map((submission) => ({ + submissionId: submission.id, + fingerprint: contentFingerprint(submission), + })); + + const report = { + challengeId: bundle.challenge.id, + sponsorId: bundle.challenge.sponsorId, + generatedAt: bundle.generatedAt || new Date().toISOString(), + summary: { + submissions: bundle.submissions.length, + findings: findings.length, + duplicateFindings: duplicateFindings.length, + conflictFindings: assignmentFindings.length, + timingFindings: timingFindings.length, + releaseReady: payoutDecisions.filter((decision) => decision.payoutStatus === "release-ready").length, + manualReview: payoutDecisions.filter((decision) => decision.payoutStatus === "manual-review").length, + holdForArbitration: payoutDecisions.filter((decision) => decision.payoutStatus === "hold-for-arbitration").length, + }, + duplicateFindings, + conflictFindings: assignmentFindings, + timingFindings, + payoutDecisions, + arbitrationQueue, + submissionFingerprints, + }; + + return { + ...report, + auditDigest: stableHash({ + challengeId: report.challengeId, + findings, + payoutDecisions, + submissionFingerprints, + }), + }; +} + +module.exports = { + analyzeScientificBountyRisk, + compareSubmissions, + contentFingerprint, + detectRelationshipConflicts, + detectTimingAndNetworkSignals, + jaccardSimilarity, + payoutDecisionForSubmission, + riskScoreForSubmission, + stableHash, + stableStringify, +}; diff --git a/scientific-bounty-risk-controls/test.js b/scientific-bounty-risk-controls/test.js new file mode 100644 index 0000000..f9970c3 --- /dev/null +++ b/scientific-bounty-risk-controls/test.js @@ -0,0 +1,76 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + analyzeScientificBountyRisk, + compareSubmissions, + contentFingerprint, + detectTimingAndNetworkSignals, + jaccardSimilarity, +} = require("./src/bounty-risk-controls"); + +const samplePath = path.join(__dirname, "sample-data.json"); +const sample = JSON.parse(fs.readFileSync(samplePath, "utf8")); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +{ + const score = jaccardSimilarity( + "single cell microglia inflammatory biomarker validation", + "microglia inflammatory marker validation from single-cell cohorts" + ); + assert.ok(score >= 0.4, `expected related method text similarity, got ${score}`); +} + +{ + const findings = compareSubmissions(sample.submissions); + assert.ok(findings.some((finding) => finding.code === "shared-artifact-hash")); + assert.ok(findings.some((finding) => finding.submissionIds.includes("sub-atlas") && finding.submissionIds.includes("sub-mirror"))); +} + +{ + const signals = detectTimingAndNetworkSignals(sample.submissions); + assert.ok(signals.some((finding) => finding.code === "shared-anonymized-network")); + assert.ok(signals.some((finding) => finding.code === "submission-burst")); +} + +{ + const report = analyzeScientificBountyRisk(sample); + assert.equal(report.challengeId, "challenge-biomarker-2026"); + assert.equal(report.summary.submissions, 4); + assert.ok(report.summary.duplicateFindings >= 1); + assert.ok(report.summary.conflictFindings >= 3); + assert.match(report.auditDigest, /^[a-f0-9]{64}$/); + + const mirrorDecision = report.payoutDecisions.find((decision) => decision.submissionId === "sub-mirror"); + assert.equal(mirrorDecision.payoutStatus, "hold-for-arbitration"); + assert.ok(mirrorDecision.holdReasons.some((reason) => reason.code === "shared-artifact-hash")); + assert.ok(mirrorDecision.holdReasons.some((reason) => reason.code === "sponsor-employee-reviewer")); + + const latticeDecision = report.payoutDecisions.find((decision) => decision.submissionId === "sub-lattice"); + assert.equal(latticeDecision.payoutStatus, "release-ready"); +} + +{ + const report = analyzeScientificBountyRisk(sample); + const changed = clone(sample); + changed.submissions[1].artifacts.push({ + name: "unexpected-shared-table.csv", + hash: "sha256:atlas-biomarkers-001", + }); + const updated = analyzeScientificBountyRisk(changed); + assert.notEqual(updated.auditDigest, report.auditDigest); + assert.notEqual(contentFingerprint(changed.submissions[1]), contentFingerprint(sample.submissions[1])); +} + +{ + const invalid = clone(sample); + delete invalid.reviewers; + assert.throws(() => analyzeScientificBountyRisk(invalid), /missing required bundle array: reviewers/); +} + +console.log("scientific-bounty-risk-controls tests passed");