diff --git a/scientific-bounty-review-integrity/README.md b/scientific-bounty-review-integrity/README.md new file mode 100644 index 00000000..a4094c4d --- /dev/null +++ b/scientific-bounty-review-integrity/README.md @@ -0,0 +1,50 @@ +# Scientific Bounty Review Integrity + +This module adds a dependency-free integrity layer for the Scientific Bounty System in issue #18. It focuses on the review, arbitration, payout-readiness, and IP handoff parts of a scientific challenge workflow. + +## What It Covers + +- Evidence manifests with deterministic SHA-256 hashes for submitted artifacts. +- Required deliverable checks for whitepapers, datasets, notebooks, or any challenge-defined artifact type. +- Reviewer conflict detection by team, affiliation, and declared conflict list. +- Weighted rubric scoring with passing-threshold evaluation. +- Milestone payout planning split across contributor shares. +- IP transfer state that keeps solver IP retained until payout is ready. +- Audit hash for sponsor/reviewer traceability. + +## Run The Demo + +```bash +node scientific-bounty-review-integrity/demo.js +``` + +The demo prints a reviewer-ready JSON decision record containing evidence hashes, conflict review IDs, scoring, payout readiness, payout splits, and the final audit hash. + +## Visual Demo + +Open `scientific-bounty-review-integrity/docs/demo.svg` for a privacy-safe walkthrough of the module flow. It uses only synthetic sample data and shows how a submission moves from evidence hashing through reviewer conflict checks, rubric scoring, payout readiness, and IP handoff. + +For bounty review, the same walkthrough is also available as a short WebM demo video at `scientific-bounty-review-integrity/docs/demo.webm`. + +## Run The Tests + +```bash +node scientific-bounty-review-integrity/test.js +``` + +The tests cover a passing submission, a missing-deliverable blocker, conflicted reviewer exclusion, deterministic hashing, and milestone payout splitting. + +## Requirement Mapping + +| Issue #18 requirement | Implementation | +| --- | --- | +| Submission package manifest | `buildEvidenceManifest()` hashes and records artifact metadata. | +| Arbitration and reviewer validation | `evaluateScientificBounty()` filters conflicted reviews before scoring. | +| Evaluation criteria and scoring rubric | Weighted rubric validation and score aggregation are enforced. | +| Milestone and prize payout routing | `buildMilestonePayouts()` creates contributor-level payment splits. | +| IP management options | `ipTransferState` is blocked until the submission is payout-ready. | +| Sponsor trust and reproducibility | `auditHash` creates a stable decision record for later verification. | + +## Design Notes + +The module intentionally uses only Node.js built-ins. It can be embedded into a future API, worker, or CLI without adding package-manager dependencies or credentials. diff --git a/scientific-bounty-review-integrity/demo.js b/scientific-bounty-review-integrity/demo.js new file mode 100644 index 00000000..6bd67c6b --- /dev/null +++ b/scientific-bounty-review-integrity/demo.js @@ -0,0 +1,6 @@ +const {evaluateScientificBounty} = require("./index"); +const {challenge, reviews, submission} = require("./sample-data"); + +const result = evaluateScientificBounty(challenge, submission, reviews); + +console.log(JSON.stringify(result, null, 2)); diff --git a/scientific-bounty-review-integrity/docs/demo.svg b/scientific-bounty-review-integrity/docs/demo.svg new file mode 100644 index 00000000..acc80b45 --- /dev/null +++ b/scientific-bounty-review-integrity/docs/demo.svg @@ -0,0 +1,79 @@ + + Scientific bounty review integrity demo + Privacy-safe workflow demo showing synthetic scientific bounty data moving through evidence hashing, conflict screening, scoring, payout readiness, and IP handoff. + + + + + + + + + Scientific Bounty Review Integrity + Synthetic demo: no credentials, no live research data, no external services. + + + 1. Submission + Whitepaper, dataset, notebook + submissionId: SYN-BIO-42 + contributors: 70% / 30% + + + + + 2. Evidence Manifest + Deterministic SHA-256 hashes + + 3 artifacts hashed + + + + + 3. Conflict Screen + Reviewer team, affiliation, declarations + + 1 review excluded + + + + + 4. Rubric Score + Weighted criteria after conflict removal + + score: 86 / pass + + + + + 5. Payout Readiness + No missing deliverables or blockers + + ready_for_sponsor_approval + + + + + 6. IP Handoff + IP is retained until payout readiness + + eligible_after_payout + + + Audit Result + auditHash: 04edceba36... | payoutPlan: milestone split across contributor shares + + diff --git a/scientific-bounty-review-integrity/docs/demo.webm b/scientific-bounty-review-integrity/docs/demo.webm new file mode 100644 index 00000000..f5a063b0 Binary files /dev/null and b/scientific-bounty-review-integrity/docs/demo.webm differ diff --git a/scientific-bounty-review-integrity/index.js b/scientific-bounty-review-integrity/index.js new file mode 100644 index 00000000..7df64ab8 --- /dev/null +++ b/scientific-bounty-review-integrity/index.js @@ -0,0 +1,185 @@ +const crypto = require("crypto"); + +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 sha256(value) { + return crypto.createHash("sha256").update(stableStringify(value)).digest("hex"); +} + +function assertArray(name, value) { + if (!Array.isArray(value)) { + throw new TypeError(`${name} must be an array`); + } +} + +function validateRubric(rubric) { + assertArray("challenge.rubric", rubric); + const totalWeight = rubric.reduce((sum, item) => sum + item.weight, 0); + const duplicateIds = rubric + .map((item) => item.id) + .filter((id, index, ids) => ids.indexOf(id) !== index); + + return { + ok: totalWeight === 100 && duplicateIds.length === 0, + totalWeight, + duplicateIds: [...new Set(duplicateIds)], + }; +} + +function buildEvidenceManifest(submission) { + assertArray("submission.artifacts", submission.artifacts); + + return submission.artifacts.map((artifact) => ({ + id: artifact.id, + type: artifact.type, + name: artifact.name, + hash: sha256({ + name: artifact.name, + type: artifact.type, + content: artifact.content, + metadata: artifact.metadata || {}, + }), + declaredLicense: artifact.license || null, + })); +} + +function findMissingDeliverables(challenge, submission) { + const providedTypes = new Set(submission.artifacts.map((artifact) => artifact.type)); + return challenge.requiredDeliverables.filter((type) => !providedTypes.has(type)); +} + +function reviewerHasConflict(reviewer, submission) { + const declared = new Set(reviewer.declaredConflicts || []); + return ( + reviewer.teamId === submission.teamId || + reviewer.affiliation === submission.affiliation || + declared.has(submission.teamId) || + declared.has(submission.affiliation) + ); +} + +function scoreSubmission(challenge, reviews) { + const rubricById = new Map(challenge.rubric.map((item) => [item.id, item])); + const acceptedReviews = reviews.filter((review) => review.status === "accepted"); + const scoreTotals = new Map(); + + for (const review of acceptedReviews) { + for (const [criterionId, value] of Object.entries(review.scores)) { + const criterion = rubricById.get(criterionId); + if (!criterion) { + continue; + } + if (value < 0 || value > criterion.maxScore) { + throw new RangeError(`${criterionId} score must be between 0 and ${criterion.maxScore}`); + } + const normalized = value / criterion.maxScore; + const weighted = normalized * criterion.weight; + scoreTotals.set(criterionId, (scoreTotals.get(criterionId) || 0) + weighted); + } + } + + const reviewCount = Math.max(acceptedReviews.length, 1); + const weightedScore = [...scoreTotals.values()].reduce((sum, value) => sum + value, 0) / reviewCount; + + return { + acceptedReviewCount: acceptedReviews.length, + weightedScore: Number(weightedScore.toFixed(2)), + passed: weightedScore >= challenge.passingScore, + }; +} + +function buildMilestonePayouts(challenge, submission, score) { + if (!score.passed) { + return []; + } + + assertArray("challenge.milestones", challenge.milestones); + assertArray("submission.contributors", submission.contributors); + + const shareTotal = submission.contributors.reduce((sum, contributor) => sum + contributor.share, 0); + if (shareTotal !== 100) { + throw new RangeError("submission contributor shares must total 100"); + } + + return challenge.milestones.map((milestone) => ({ + milestoneId: milestone.id, + amountUsd: milestone.amountUsd, + status: "ready", + recipients: submission.contributors.map((contributor) => ({ + contributorId: contributor.id, + amountUsd: Number(((milestone.amountUsd * contributor.share) / 100).toFixed(2)), + })), + })); +} + +function evaluateScientificBounty(challenge, submission, reviews) { + assertArray("challenge.requiredDeliverables", challenge.requiredDeliverables); + assertArray("reviews", reviews); + + const rubric = validateRubric(challenge.rubric); + const evidenceManifest = buildEvidenceManifest(submission); + const missingDeliverables = findMissingDeliverables(challenge, submission); + const conflictReviews = reviews.filter((review) => reviewerHasConflict(review.reviewer, submission)); + const acceptedReviews = reviews + .filter((review) => !reviewerHasConflict(review.reviewer, submission)) + .map((review) => ({...review, status: "accepted"})); + const score = scoreSubmission(challenge, acceptedReviews); + const blockers = []; + + if (!rubric.ok) { + blockers.push("rubric_invalid"); + } + if (missingDeliverables.length > 0) { + blockers.push("missing_deliverables"); + } + if (acceptedReviews.length < challenge.minimumIndependentReviews) { + blockers.push("insufficient_independent_reviews"); + } + if (!score.passed) { + blockers.push("score_below_threshold"); + } + + const payoutPlan = blockers.length === 0 ? buildMilestonePayouts(challenge, submission, score) : []; + + return { + challengeId: challenge.id, + submissionId: submission.id, + evidenceManifest, + rubric, + missingDeliverables, + conflictReviewIds: conflictReviews.map((review) => review.id), + score, + blockers, + payoutReadiness: blockers.length === 0 ? "ready_for_sponsor_approval" : "blocked", + ipTransferState: blockers.length === 0 ? "eligible_after_payout" : "retained_by_solver", + payoutPlan, + auditHash: sha256({ + challengeId: challenge.id, + submissionId: submission.id, + evidenceManifest, + score, + blockers, + payoutPlan, + }), + }; +} + +module.exports = { + buildEvidenceManifest, + evaluateScientificBounty, + sha256, + stableStringify, +}; diff --git a/scientific-bounty-review-integrity/sample-data.js b/scientific-bounty-review-integrity/sample-data.js new file mode 100644 index 00000000..b7d97254 --- /dev/null +++ b/scientific-bounty-review-integrity/sample-data.js @@ -0,0 +1,85 @@ +const challenge = { + id: "challenge-biomarker-001", + title: "Identify candidate biomarkers from single-cell RNA-seq data", + requiredDeliverables: ["whitepaper", "dataset", "notebook"], + minimumIndependentReviews: 2, + passingScore: 82, + rubric: [ + {id: "scientific_validity", label: "Scientific validity", weight: 40, maxScore: 10}, + {id: "reproducibility", label: "Reproducibility", weight: 30, maxScore: 10}, + {id: "delivery_quality", label: "Delivery quality", weight: 20, maxScore: 10}, + {id: "ip_readiness", label: "IP readiness", weight: 10, maxScore: 10}, + ], + milestones: [ + {id: "phase-1-accepted", amountUsd: 300}, + {id: "final-award", amountUsd: 700}, + ], +}; + +const submission = { + id: "submission-team-42", + teamId: "team-42", + affiliation: "Open Biomarker Lab", + contributors: [ + {id: "researcher-a", share: 60}, + {id: "researcher-b", share: 40}, + ], + artifacts: [ + { + id: "artifact-whitepaper", + type: "whitepaper", + name: "biomarker-findings.md", + license: "CC-BY-4.0", + content: "Ranked biomarker candidates with validation rationale.", + }, + { + id: "artifact-dataset", + type: "dataset", + name: "marker-evidence.csv", + license: "CC-BY-4.0", + content: "gene,score\nCD74,0.91\nCXCL10,0.87", + }, + { + id: "artifact-notebook", + type: "notebook", + name: "analysis.ipynb", + license: "MIT", + content: {cells: [{source: "normalize_counts(); rank_markers();"}]}, + }, + ], +}; + +const reviews = [ + { + id: "review-1", + reviewer: {id: "reviewer-1", affiliation: "Independent Review Guild", teamId: "reviewer-team-a"}, + scores: { + scientific_validity: 9, + reproducibility: 8, + delivery_quality: 9, + ip_readiness: 8, + }, + }, + { + id: "review-2", + reviewer: {id: "reviewer-2", affiliation: "University Validation Center", teamId: "reviewer-team-b"}, + scores: { + scientific_validity: 8, + reproducibility: 9, + delivery_quality: 9, + ip_readiness: 9, + }, + }, + { + id: "review-conflicted", + reviewer: {id: "reviewer-3", affiliation: "Open Biomarker Lab", teamId: "reviewer-team-c"}, + scores: { + scientific_validity: 10, + reproducibility: 10, + delivery_quality: 10, + ip_readiness: 10, + }, + }, +]; + +module.exports = {challenge, reviews, submission}; diff --git a/scientific-bounty-review-integrity/test.js b/scientific-bounty-review-integrity/test.js new file mode 100644 index 00000000..13924b8c --- /dev/null +++ b/scientific-bounty-review-integrity/test.js @@ -0,0 +1,34 @@ +const assert = require("assert"); +const {evaluateScientificBounty, stableStringify} = require("./index"); +const {challenge, reviews, submission} = require("./sample-data"); + +const result = evaluateScientificBounty(challenge, submission, reviews); + +assert.strictEqual(result.payoutReadiness, "ready_for_sponsor_approval"); +assert.strictEqual(result.ipTransferState, "eligible_after_payout"); +assert.deepStrictEqual(result.missingDeliverables, []); +assert.deepStrictEqual(result.conflictReviewIds, ["review-conflicted"]); +assert.strictEqual(result.score.acceptedReviewCount, 2); +assert.strictEqual(result.score.passed, true); +assert.strictEqual(result.payoutPlan.length, 2); +assert.deepStrictEqual(result.payoutPlan[1].recipients, [ + {contributorId: "researcher-a", amountUsd: 420}, + {contributorId: "researcher-b", amountUsd: 280}, +]); + +const blockedSubmission = { + ...submission, + artifacts: submission.artifacts.filter((artifact) => artifact.type !== "notebook"), +}; +const blocked = evaluateScientificBounty(challenge, blockedSubmission, reviews); + +assert.strictEqual(blocked.payoutReadiness, "blocked"); +assert.deepStrictEqual(blocked.missingDeliverables, ["notebook"]); +assert.strictEqual(blocked.payoutPlan.length, 0); +assert.ok(blocked.blockers.includes("missing_deliverables")); + +const first = stableStringify({b: 2, a: 1}); +const second = stableStringify({a: 1, b: 2}); +assert.strictEqual(first, second); + +console.log("scientific-bounty-review-integrity tests passed");