Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions scientific-bounty-review-integrity/README.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions scientific-bounty-review-integrity/demo.js
Original file line number Diff line number Diff line change
@@ -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));
79 changes: 79 additions & 0 deletions scientific-bounty-review-integrity/docs/demo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
185 changes: 185 additions & 0 deletions scientific-bounty-review-integrity/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading