diff --git a/scientific-bounty-award-publication-guard/README.md b/scientific-bounty-award-publication-guard/README.md new file mode 100644 index 00000000..1037cb80 --- /dev/null +++ b/scientific-bounty-award-publication-guard/README.md @@ -0,0 +1,53 @@ +# Scientific Bounty Award Publication Guard + +This module adds a focused award-publication readiness guard for the Scientific Bounty System bounty. + +It answers one operational question: + +> Can the platform publicly announce a winner, publish the award page, and expose result metadata without violating payout, appeal, IP, NDA, embargo, or team-consent rules? + +## Scope + +This is intentionally separate from intake, scoring, escrow funding, export-control, duplicate-submission, IP-policy, appeal-ledger, and payout-routing modules. It runs after an award decision exists and before the result is made public. + +The guard checks: + +- award decision and sponsor approval are present +- payout is authorized or released before public winner publication +- appeal window has closed or has an approved waiver +- IP handoff or open-license terms are satisfied before result publication +- embargo dates have passed +- private/NDA challenge content is redacted before public release +- named team announcements have solver consent +- award splits sum to 100 percent and recipients accepted publication terms +- reproducibility package hashes are present for public claims + +## Decisions + +- `RELEASE_PUBLICATION`: ready to publish the award and winner metadata. +- `HOLD_FOR_REVIEW`: not ready, but remediable without changing the award. +- `BLOCK_PUBLICATION`: publication would violate a hard trust rule. + +## Run + +```bash +npm test +npm run demo +``` + +Demo outputs are written to `artifacts/`: + +- `award-publication-results.json` +- `award-publication-report.md` +- `award-publication-summary.svg` +- `award-publication-demo.mp4` + +## Boundaries + +- synthetic records only +- no payment processor calls +- no private submissions +- no credentials +- no external APIs +- no legal advice + diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-demo.mp4 b/scientific-bounty-award-publication-guard/artifacts/award-publication-demo.mp4 new file mode 100644 index 00000000..a710e06d Binary files /dev/null and b/scientific-bounty-award-publication-guard/artifacts/award-publication-demo.mp4 differ diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-report.md b/scientific-bounty-award-publication-guard/artifacts/award-publication-report.md new file mode 100644 index 00000000..5e6386f6 --- /dev/null +++ b/scientific-bounty-award-publication-guard/artifacts/award-publication-report.md @@ -0,0 +1,70 @@ +# Award Publication Readiness Report + +Generated: 2026-06-12T00:00:00Z + +## Summary + +- Blocking findings: 5 +- Review findings: 3 +- Passing findings: 20 + +## Challenge Decisions + +### biomarker-single-cell-2026 + +- Decision: RELEASE_PUBLICATION +- Award: award-ready-001 +- Payout state: released +- Public mode: public_challenge_public_award + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | award_decision_ready | Award decision and sponsor approval are present. | No remediation required. | +| pass | payout_ready | Payout state allows public award publication. | No remediation required. | +| pass | appeal_window_closed | Appeal window is closed. | No remediation required. | +| pass | ip_terms_ready | IP or license terms allow publication. | No remediation required. | +| pass | embargo_clear | No active embargo blocks publication. | No remediation required. | +| pass | privacy_release_ready | Publication privacy mode is satisfied. | No remediation required. | +| pass | named_consent_ready | All named recipients consented to public announcement. | No remediation required. | +| pass | award_splits_ready | Award splits and recipient publication terms are complete. | No remediation required. | +| pass | reproducibility_manifest_ready | Reproducibility manifest has public claim evidence. | No remediation required. | + +### climate-forecast-region-pack + +- Decision: HOLD_FOR_REVIEW +- Award: award-hold-002 +- Payout state: authorized +- Public mode: public_challenge_public_award + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | award_decision_ready | Award decision and sponsor approval are present. | No remediation required. | +| pass | payout_ready | Payout state allows public award publication. | No remediation required. | +| pass | appeal_window_closed | Appeal window is closed. | No remediation required. | +| pass | ip_terms_ready | IP or license terms allow publication. | No remediation required. | +| review | embargo_active | Publication embargo remains active until 2026-06-20T00:00:00.000Z. | Hold public publication until the embargo expires. | +| pass | privacy_release_ready | Publication privacy mode is satisfied. | No remediation required. | +| pass | named_consent_ready | All named recipients consented to public announcement. | No remediation required. | +| pass | award_splits_ready | Award splits and recipient publication terms are complete. | No remediation required. | +| pass | reproducibility_manifest_ready | Reproducibility manifest has public claim evidence. | No remediation required. | + +### private-quantum-noise-reduction + +- Decision: BLOCK_PUBLICATION +- Award: award-block-003 +- Payout state: pending +- Public mode: private_challenge_public_award + +| Severity | Code | Message | Remediation | +| --- | --- | --- | --- | +| pass | award_decision_ready | Award decision and sponsor approval are present. | No remediation required. | +| block | payout_not_authorized | Public winner publication requires an authorized, released, or paid payout state. | Move payout to authorized, released, or paid before publishing named winners. | +| pass | appeal_waived | Appeal window was explicitly waived. | No remediation required. | +| block | ip_transfer_incomplete | IP transfer publication requires signed handoff and released or paid payout. | Complete signed IP handoff and release or pay the payout before publication. | +| pass | embargo_clear | No active embargo blocks publication. | No remediation required. | +| block | private_content_unredacted | Private or NDA challenge content needs approved public redaction before award publication. | Approve a public redaction packet for private challenge content. | +| review | named_consent_missing | Named publication consent missing for solver-epsilon. | Collect named publication consent or publish anonymized winners. | +| block | award_splits_invalid | Award split percentages must sum to 100; received 90. | Correct award splits so recipient percentages total exactly 100. | +| block | recipient_terms_missing | Publication terms missing for solver-epsilon. | Collect publication terms from each payout recipient. | +| review | reproducibility_manifest_incomplete | Public claims should include artifact hash and result digest. | Attach artifact hash and result digest before publishing technical claims. | + diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-results.json b/scientific-bounty-award-publication-guard/artifacts/award-publication-results.json new file mode 100644 index 00000000..23497a6d --- /dev/null +++ b/scientific-bounty-award-publication-guard/artifacts/award-publication-results.json @@ -0,0 +1,258 @@ +[ + { + "challengeId": "biomarker-single-cell-2026", + "awardId": "award-ready-001", + "decision": "RELEASE_PUBLICATION", + "checkedAt": "2026-06-12T00:00:00.000Z", + "summary": { + "blocks": 0, + "reviews": 0, + "passes": 9 + }, + "findings": [ + { + "code": "award_decision_ready", + "severity": "pass", + "hardBlock": false, + "message": "Award decision and sponsor approval are present.", + "remediation": "No remediation required." + }, + { + "code": "payout_ready", + "severity": "pass", + "hardBlock": false, + "message": "Payout state allows public award publication.", + "remediation": "No remediation required." + }, + { + "code": "appeal_window_closed", + "severity": "pass", + "hardBlock": false, + "message": "Appeal window is closed.", + "remediation": "No remediation required." + }, + { + "code": "ip_terms_ready", + "severity": "pass", + "hardBlock": false, + "message": "IP or license terms allow publication.", + "remediation": "No remediation required." + }, + { + "code": "embargo_clear", + "severity": "pass", + "hardBlock": false, + "message": "No active embargo blocks publication.", + "remediation": "No remediation required." + }, + { + "code": "privacy_release_ready", + "severity": "pass", + "hardBlock": false, + "message": "Publication privacy mode is satisfied.", + "remediation": "No remediation required." + }, + { + "code": "named_consent_ready", + "severity": "pass", + "hardBlock": false, + "message": "All named recipients consented to public announcement.", + "remediation": "No remediation required." + }, + { + "code": "award_splits_ready", + "severity": "pass", + "hardBlock": false, + "message": "Award splits and recipient publication terms are complete.", + "remediation": "No remediation required." + }, + { + "code": "reproducibility_manifest_ready", + "severity": "pass", + "hardBlock": false, + "message": "Reproducibility manifest has public claim evidence.", + "remediation": "No remediation required." + } + ], + "audit": { + "publicMode": "public_challenge_public_award", + "payoutState": "released", + "ipPolicy": "transfer_on_payout", + "embargoUntil": "2026-06-05T00:00:00Z", + "recipientCount": 2 + } + }, + { + "challengeId": "climate-forecast-region-pack", + "awardId": "award-hold-002", + "decision": "HOLD_FOR_REVIEW", + "checkedAt": "2026-06-12T00:00:00.000Z", + "summary": { + "blocks": 0, + "reviews": 1, + "passes": 8 + }, + "findings": [ + { + "code": "award_decision_ready", + "severity": "pass", + "hardBlock": false, + "message": "Award decision and sponsor approval are present.", + "remediation": "No remediation required." + }, + { + "code": "payout_ready", + "severity": "pass", + "hardBlock": false, + "message": "Payout state allows public award publication.", + "remediation": "No remediation required." + }, + { + "code": "appeal_window_closed", + "severity": "pass", + "hardBlock": false, + "message": "Appeal window is closed.", + "remediation": "No remediation required." + }, + { + "code": "ip_terms_ready", + "severity": "pass", + "hardBlock": false, + "message": "IP or license terms allow publication.", + "remediation": "No remediation required." + }, + { + "code": "embargo_active", + "severity": "review", + "hardBlock": false, + "message": "Publication embargo remains active until 2026-06-20T00:00:00.000Z.", + "remediation": "Hold public publication until the embargo expires." + }, + { + "code": "privacy_release_ready", + "severity": "pass", + "hardBlock": false, + "message": "Publication privacy mode is satisfied.", + "remediation": "No remediation required." + }, + { + "code": "named_consent_ready", + "severity": "pass", + "hardBlock": false, + "message": "All named recipients consented to public announcement.", + "remediation": "No remediation required." + }, + { + "code": "award_splits_ready", + "severity": "pass", + "hardBlock": false, + "message": "Award splits and recipient publication terms are complete.", + "remediation": "No remediation required." + }, + { + "code": "reproducibility_manifest_ready", + "severity": "pass", + "hardBlock": false, + "message": "Reproducibility manifest has public claim evidence.", + "remediation": "No remediation required." + } + ], + "audit": { + "publicMode": "public_challenge_public_award", + "payoutState": "authorized", + "ipPolicy": "open_license", + "embargoUntil": "2026-06-20T00:00:00Z", + "recipientCount": 1 + } + }, + { + "challengeId": "private-quantum-noise-reduction", + "awardId": "award-block-003", + "decision": "BLOCK_PUBLICATION", + "checkedAt": "2026-06-12T00:00:00.000Z", + "summary": { + "blocks": 5, + "reviews": 2, + "passes": 3 + }, + "findings": [ + { + "code": "award_decision_ready", + "severity": "pass", + "hardBlock": false, + "message": "Award decision and sponsor approval are present.", + "remediation": "No remediation required." + }, + { + "code": "payout_not_authorized", + "severity": "block", + "hardBlock": true, + "message": "Public winner publication requires an authorized, released, or paid payout state.", + "remediation": "Move payout to authorized, released, or paid before publishing named winners." + }, + { + "code": "appeal_waived", + "severity": "pass", + "hardBlock": false, + "message": "Appeal window was explicitly waived.", + "remediation": "No remediation required." + }, + { + "code": "ip_transfer_incomplete", + "severity": "block", + "hardBlock": true, + "message": "IP transfer publication requires signed handoff and released or paid payout.", + "remediation": "Complete signed IP handoff and release or pay the payout before publication." + }, + { + "code": "embargo_clear", + "severity": "pass", + "hardBlock": false, + "message": "No active embargo blocks publication.", + "remediation": "No remediation required." + }, + { + "code": "private_content_unredacted", + "severity": "block", + "hardBlock": true, + "message": "Private or NDA challenge content needs approved public redaction before award publication.", + "remediation": "Approve a public redaction packet for private challenge content." + }, + { + "code": "named_consent_missing", + "severity": "review", + "hardBlock": false, + "message": "Named publication consent missing for solver-epsilon.", + "remediation": "Collect named publication consent or publish anonymized winners." + }, + { + "code": "award_splits_invalid", + "severity": "block", + "hardBlock": true, + "message": "Award split percentages must sum to 100; received 90.", + "remediation": "Correct award splits so recipient percentages total exactly 100." + }, + { + "code": "recipient_terms_missing", + "severity": "block", + "hardBlock": true, + "message": "Publication terms missing for solver-epsilon.", + "remediation": "Collect publication terms from each payout recipient." + }, + { + "code": "reproducibility_manifest_incomplete", + "severity": "review", + "hardBlock": false, + "message": "Public claims should include artifact hash and result digest.", + "remediation": "Attach artifact hash and result digest before publishing technical claims." + } + ], + "audit": { + "publicMode": "private_challenge_public_award", + "payoutState": "pending", + "ipPolicy": "transfer_on_payout", + "embargoUntil": null, + "recipientCount": 2 + } + } +] diff --git a/scientific-bounty-award-publication-guard/artifacts/award-publication-summary.svg b/scientific-bounty-award-publication-guard/artifacts/award-publication-summary.svg new file mode 100644 index 00000000..d5d1cc6a --- /dev/null +++ b/scientific-bounty-award-publication-guard/artifacts/award-publication-summary.svg @@ -0,0 +1,23 @@ + + + Scientific Bounty Award Publication Guard + blocks=5 reviews=3 passes=20 + + biomarker-single-cell-2026 + + RELEASE_PUBLICATION + + climate-forecast-region-pack + + HOLD_FOR_REVIEW + + private-quantum-noise-reduction + + BLOCK_PUBLICATION + + diff --git a/scientific-bounty-award-publication-guard/examples/award-publication-packets.json b/scientific-bounty-award-publication-guard/examples/award-publication-packets.json new file mode 100644 index 00000000..94befa9a --- /dev/null +++ b/scientific-bounty-award-publication-guard/examples/award-publication-packets.json @@ -0,0 +1,119 @@ +[ + { + "challengeId": "biomarker-single-cell-2026", + "award": { + "id": "award-ready-001", + "state": "awarded", + "sponsorApproved": true, + "splits": [ + { + "recipientId": "lab-alpha", + "percent": 60, + "namedConsent": true, + "acceptedPublicationTerms": true + }, + { + "recipientId": "researcher-beta", + "percent": 40, + "namedConsent": true, + "acceptedPublicationTerms": true + } + ] + }, + "payout": { + "state": "released" + }, + "appeal": { + "closesAt": "2026-06-01T00:00:00Z" + }, + "ip": { + "policy": "transfer_on_payout", + "transferSigned": true + }, + "publication": { + "mode": "public_challenge_public_award", + "namedWinners": true, + "embargoUntil": "2026-06-05T00:00:00Z" + }, + "reproducibilityManifest": { + "artifactHash": "sha256:11c6db0f84b4f7f8f7fa6b8da6c89bda61b24f71d111bd3d31ec2d557ec9b9a0", + "resultDigest": "ranked model package reproduced with held-out validation report" + } + }, + { + "challengeId": "climate-forecast-region-pack", + "award": { + "id": "award-hold-002", + "state": "awarded", + "sponsorApproved": true, + "splits": [ + { + "recipientId": "team-gamma", + "percent": 100, + "namedConsent": true, + "acceptedPublicationTerms": true + } + ] + }, + "payout": { + "state": "authorized" + }, + "appeal": { + "closesAt": "2026-06-10T00:00:00Z" + }, + "ip": { + "policy": "open_license", + "licenseId": "CC-BY-4.0" + }, + "publication": { + "mode": "public_challenge_public_award", + "namedWinners": true, + "embargoUntil": "2026-06-20T00:00:00Z" + }, + "reproducibilityManifest": { + "artifactHash": "sha256:6b1b9d9487c8be834df51d1bc67b0df4c475a4329a077ad10b7cf8a198cb9212", + "resultDigest": "forecast benchmark notebook reproduced with station holdout" + } + }, + { + "challengeId": "private-quantum-noise-reduction", + "award": { + "id": "award-block-003", + "state": "awarded", + "sponsorApproved": true, + "splits": [ + { + "recipientId": "solver-delta", + "percent": 70, + "namedConsent": true, + "acceptedPublicationTerms": true + }, + { + "recipientId": "solver-epsilon", + "percent": 20, + "namedConsent": false, + "acceptedPublicationTerms": false + } + ] + }, + "payout": { + "state": "pending" + }, + "appeal": { + "waived": true + }, + "ip": { + "policy": "transfer_on_payout", + "transferSigned": false + }, + "publication": { + "mode": "private_challenge_public_award", + "namedWinners": true, + "redactionApproved": false + }, + "reproducibilityManifest": { + "artifactHash": "", + "resultDigest": "" + } + } +] diff --git a/scientific-bounty-award-publication-guard/package.json b/scientific-bounty-award-publication-guard/package.json new file mode 100644 index 00000000..3527c092 --- /dev/null +++ b/scientific-bounty-award-publication-guard/package.json @@ -0,0 +1,11 @@ +{ + "name": "scientific-bounty-award-publication-guard", + "version": "1.0.0", + "description": "Deterministic publication readiness guard for scientific bounty award announcements.", + "type": "module", + "scripts": { + "test": "node test/awardPublicationGuard.test.js", + "demo": "node scripts/demo.js" + }, + "license": "MIT" +} diff --git a/scientific-bounty-award-publication-guard/scripts/demo.js b/scientific-bounty-award-publication-guard/scripts/demo.js new file mode 100644 index 00000000..3620af3b --- /dev/null +++ b/scientific-bounty-award-publication-guard/scripts/demo.js @@ -0,0 +1,147 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { spawnSync } from "node:child_process" +import { join } from "node:path" +import { tmpdir } from "node:os" +import packets from "../examples/award-publication-packets.json" with { type: "json" } +import { evaluateAwardPublicationBatch, summarize } from "../src/index.js" + +const ARTIFACT_DIR = new URL("../artifacts/", import.meta.url) +const now = "2026-06-12T00:00:00Z" +const results = evaluateAwardPublicationBatch(packets, { now }) +const summary = summarize(results) + +await mkdir(ARTIFACT_DIR, { recursive: true }) +await writeFile(new URL("award-publication-results.json", ARTIFACT_DIR), `${JSON.stringify(results, null, 2)}\n`) +await writeFile(new URL("award-publication-report.md", ARTIFACT_DIR), renderReport(results, summary)) +await writeFile(new URL("award-publication-summary.svg", ARTIFACT_DIR), renderSvg(results, summary)) +await renderMp4() + +console.log("Award publication guard demo generated") +console.log(`- decisions: ${results.map((result) => `${result.challengeId}:${result.decision}`).join(", ")}`) +console.log(`- summary: ${JSON.stringify(summary)}`) + +function renderReport(results, summary) { + const lines = [ + "# Award Publication Readiness Report", + "", + `Generated: ${now}`, + "", + "## Summary", + "", + `- Blocking findings: ${summary.blocks}`, + `- Review findings: ${summary.reviews}`, + `- Passing findings: ${summary.passes}`, + "", + "## Challenge Decisions", + "" + ] + + for (const result of results) { + lines.push(`### ${result.challengeId}`) + lines.push("") + lines.push(`- Decision: ${result.decision}`) + lines.push(`- Award: ${result.awardId}`) + lines.push(`- Payout state: ${result.audit.payoutState}`) + lines.push(`- Public mode: ${result.audit.publicMode}`) + lines.push("") + lines.push("| Severity | Code | Message | Remediation |") + lines.push("| --- | --- | --- | --- |") + for (const finding of result.findings) { + lines.push(`| ${finding.severity} | ${finding.code} | ${finding.message} | ${finding.remediation} |`) + } + lines.push("") + } + + return `${lines.join("\n")}\n` +} + +function renderSvg(results, summary) { + const rows = results.map((result, index) => { + const y = 128 + index * 64 + const color = result.decision === "RELEASE_PUBLICATION" ? "#15803d" : result.decision === "HOLD_FOR_REVIEW" ? "#b45309" : "#b91c1c" + return ` + ${escapeXml(result.challengeId)} + + ${result.decision}` + }).join("\n") + + return ` + + Scientific Bounty Award Publication Guard + blocks=${summary.blocks} reviews=${summary.reviews} passes=${summary.passes} + ${rows} + + +` +} + +async function renderMp4() { + const ppmPath = join(tmpdir(), `award-publication-demo-frame-${Date.now()}.ppm`) + const mp4Path = new URL("award-publication-demo.mp4", ARTIFACT_DIR) + await writeFile(ppmPath, renderPpmFrame()) + + const ffmpeg = spawnSync("ffmpeg", [ + "-y", + "-hide_banner", + "-loglevel", + "error", + "-loop", + "1", + "-framerate", + "1", + "-i", + ppmPath, + "-t", + "5", + "-pix_fmt", + "yuv420p", + mp4Path.pathname + ], { encoding: "utf8" }) + + if (ffmpeg.status !== 0) { + await writeFile(new URL("award-publication-demo-fallback.txt", ARTIFACT_DIR), ffmpeg.stderr || "ffmpeg failed") + return + } +} + +function renderPpmFrame() { + const width = 900 + const height = 360 + const pixels = Buffer.alloc(width * height * 3, 248) + + fillRect(pixels, width, 0, 0, width, height, [248, 250, 252]) + fillRect(pixels, width, 40, 50, 820, 34, [15, 23, 42]) + fillRect(pixels, width, 40, 112, 250, 72, [21, 128, 61]) + fillRect(pixels, width, 325, 112, 250, 72, [180, 83, 9]) + fillRect(pixels, width, 610, 112, 250, 72, [185, 28, 28]) + fillRect(pixels, width, 40, 224, 820, 56, [226, 232, 240]) + fillRect(pixels, width, 40, 304, 260, 18, [21, 128, 61]) + fillRect(pixels, width, 320, 304, 180, 18, [180, 83, 9]) + fillRect(pixels, width, 520, 304, 300, 18, [185, 28, 28]) + + const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii") + return Buffer.concat([header, pixels]) +} + +function fillRect(pixels, imageWidth, x, y, width, height, rgb) { + for (let row = y; row < y + height; row += 1) { + for (let col = x; col < x + width; col += 1) { + const offset = (row * imageWidth + col) * 3 + pixels[offset] = rgb[0] + pixels[offset + 1] = rgb[1] + pixels[offset + 2] = rgb[2] + } + } +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") +} diff --git a/scientific-bounty-award-publication-guard/src/index.js b/scientific-bounty-award-publication-guard/src/index.js new file mode 100644 index 00000000..43fe3574 --- /dev/null +++ b/scientific-bounty-award-publication-guard/src/index.js @@ -0,0 +1,223 @@ +const HARD_BLOCKS = new Set([ + "missing_award_decision", + "payout_not_authorized", + "ip_transfer_incomplete", + "private_content_unredacted", + "award_splits_invalid", + "recipient_terms_missing" +]) + +export function evaluateAwardPublication(packet, options = {}) { + const now = normalizeDate(options.now ?? new Date()) + const findings = [] + + checkAwardDecision(packet, findings) + checkPayout(packet, findings) + checkAppealWindow(packet, findings, now) + checkIpAndLicense(packet, findings) + checkEmbargo(packet, findings, now) + checkPrivacy(packet, findings) + checkTeamConsent(packet, findings) + checkAwardSplits(packet, findings) + checkReproducibility(packet, findings) + + const hardBlocks = findings.filter((finding) => finding.severity === "block") + const reviewItems = findings.filter((finding) => finding.severity === "review") + const decision = hardBlocks.length + ? "BLOCK_PUBLICATION" + : reviewItems.length + ? "HOLD_FOR_REVIEW" + : "RELEASE_PUBLICATION" + + return { + challengeId: packet.challengeId ?? "unknown", + awardId: packet.award?.id ?? "unknown", + decision, + checkedAt: now.toISOString(), + summary: summarize(findings), + findings, + audit: { + publicMode: packet.publication?.mode ?? "unknown", + payoutState: packet.payout?.state ?? "unknown", + ipPolicy: packet.ip?.policy ?? "unknown", + embargoUntil: packet.publication?.embargoUntil ?? null, + recipientCount: packet.award?.splits?.length ?? 0 + } + } +} + +export function evaluateAwardPublicationBatch(packets, options = {}) { + return packets.map((packet) => evaluateAwardPublication(packet, options)) +} + +export function summarize(resultsOrFindings) { + const findings = Array.isArray(resultsOrFindings) + ? resultsOrFindings.flatMap((item) => item.findings ?? item) + : [] + + return { + blocks: findings.filter((finding) => finding.severity === "block").length, + reviews: findings.filter((finding) => finding.severity === "review").length, + passes: findings.filter((finding) => finding.severity === "pass").length + } +} + +function checkAwardDecision(packet, findings) { + if (!packet.award?.id || packet.award?.state !== "awarded") { + findings.push(issue("missing_award_decision", "block", "Award decision is missing or not finalized.")) + return + } + if (!packet.award?.sponsorApproved) { + findings.push(issue("sponsor_approval_missing", "review", "Sponsor has not approved the award publication.")) + return + } + findings.push(issue("award_decision_ready", "pass", "Award decision and sponsor approval are present.")) +} + +function checkPayout(packet, findings) { + const allowed = new Set(["authorized", "released", "paid"]) + if (!allowed.has(packet.payout?.state)) { + findings.push(issue("payout_not_authorized", "block", "Public winner publication requires an authorized, released, or paid payout state.")) + return + } + findings.push(issue("payout_ready", "pass", "Payout state allows public award publication.")) +} + +function checkAppealWindow(packet, findings, now) { + const appeal = packet.appeal ?? {} + if (appeal.waived === true) { + findings.push(issue("appeal_waived", "pass", "Appeal window was explicitly waived.")) + return + } + const closesAt = parseOptionalDate(appeal.closesAt) + if (!closesAt) { + findings.push(issue("appeal_window_missing", "review", "Appeal close timestamp is missing.")) + return + } + if (closesAt > now) { + findings.push(issue("appeal_window_open", "review", `Appeal window is still open until ${closesAt.toISOString()}.`)) + return + } + findings.push(issue("appeal_window_closed", "pass", "Appeal window is closed.")) +} + +function checkIpAndLicense(packet, findings) { + const ip = packet.ip ?? {} + if (ip.policy === "transfer_on_payout") { + if (ip.transferSigned !== true || !["released", "paid"].includes(packet.payout?.state)) { + findings.push(issue("ip_transfer_incomplete", "block", "IP transfer publication requires signed handoff and released or paid payout.")) + return + } + } + + if (ip.policy === "open_license" && !ip.licenseId) { + findings.push(issue("open_license_missing", "review", "Open publication requires a license identifier.")) + return + } + + findings.push(issue("ip_terms_ready", "pass", "IP or license terms allow publication.")) +} + +function checkEmbargo(packet, findings, now) { + const embargoUntil = parseOptionalDate(packet.publication?.embargoUntil) + if (embargoUntil && embargoUntil > now) { + findings.push(issue("embargo_active", "review", `Publication embargo remains active until ${embargoUntil.toISOString()}.`)) + return + } + findings.push(issue("embargo_clear", "pass", "No active embargo blocks publication.")) +} + +function checkPrivacy(packet, findings) { + const publication = packet.publication ?? {} + if (publication.mode === "private_challenge_public_award" && publication.redactionApproved !== true) { + findings.push(issue("private_content_unredacted", "block", "Private or NDA challenge content needs approved public redaction before award publication.")) + return + } + findings.push(issue("privacy_release_ready", "pass", "Publication privacy mode is satisfied.")) +} + +function checkTeamConsent(packet, findings) { + const publication = packet.publication ?? {} + if (publication.namedWinners !== true) { + findings.push(issue("anonymous_publication_ok", "pass", "Winner publication does not expose named solvers.")) + return + } + + const missing = (packet.award?.splits ?? []).filter((split) => split.namedConsent !== true) + if (missing.length) { + findings.push(issue("named_consent_missing", "review", `Named publication consent missing for ${missing.map((item) => item.recipientId).join(", ")}.`)) + return + } + findings.push(issue("named_consent_ready", "pass", "All named recipients consented to public announcement.")) +} + +function checkAwardSplits(packet, findings) { + const splits = packet.award?.splits ?? [] + const total = splits.reduce((sum, split) => sum + Number(split.percent ?? 0), 0) + let hasProblem = false + if (splits.length === 0 || Math.abs(total - 100) > 0.001) { + findings.push(issue("award_splits_invalid", "block", `Award split percentages must sum to 100; received ${total}.`)) + hasProblem = true + } + + const missingTerms = splits.filter((split) => split.acceptedPublicationTerms !== true) + if (missingTerms.length) { + findings.push(issue("recipient_terms_missing", "block", `Publication terms missing for ${missingTerms.map((item) => item.recipientId).join(", ")}.`)) + hasProblem = true + } + + if (hasProblem) { + return + } + findings.push(issue("award_splits_ready", "pass", "Award splits and recipient publication terms are complete.")) +} + +function checkReproducibility(packet, findings) { + const manifest = packet.reproducibilityManifest ?? {} + if (!manifest.artifactHash || !manifest.resultDigest) { + findings.push(issue("reproducibility_manifest_incomplete", "review", "Public claims should include artifact hash and result digest.")) + return + } + findings.push(issue("reproducibility_manifest_ready", "pass", "Reproducibility manifest has public claim evidence.")) +} + +function issue(code, severity, message) { + return { + code, + severity, + hardBlock: HARD_BLOCKS.has(code), + message, + remediation: remediationFor(code) + } +} + +function remediationFor(code) { + const remediations = { + missing_award_decision: "Finalize the award record and sponsor approval before publication.", + payout_not_authorized: "Move payout to authorized, released, or paid before publishing named winners.", + appeal_window_missing: "Record an appeal close timestamp or explicit waiver.", + appeal_window_open: "Wait until the appeal window closes or record an approved waiver.", + ip_transfer_incomplete: "Complete signed IP handoff and release or pay the payout before publication.", + open_license_missing: "Attach the open license identifier to the award record.", + embargo_active: "Hold public publication until the embargo expires.", + private_content_unredacted: "Approve a public redaction packet for private challenge content.", + named_consent_missing: "Collect named publication consent or publish anonymized winners.", + award_splits_invalid: "Correct award splits so recipient percentages total exactly 100.", + recipient_terms_missing: "Collect publication terms from each payout recipient.", + reproducibility_manifest_incomplete: "Attach artifact hash and result digest before publishing technical claims." + } + return remediations[code] ?? "No remediation required." +} + +function normalizeDate(value) { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + throw new Error(`Invalid date: ${value}`) + } + return date +} + +function parseOptionalDate(value) { + if (!value) return null + return normalizeDate(value) +} diff --git a/scientific-bounty-award-publication-guard/test/awardPublicationGuard.test.js b/scientific-bounty-award-publication-guard/test/awardPublicationGuard.test.js new file mode 100644 index 00000000..0b89c5cf --- /dev/null +++ b/scientific-bounty-award-publication-guard/test/awardPublicationGuard.test.js @@ -0,0 +1,40 @@ +import assert from "node:assert/strict" +import packets from "../examples/award-publication-packets.json" with { type: "json" } +import { evaluateAwardPublication, evaluateAwardPublicationBatch, summarize } from "../src/index.js" + +const now = "2026-06-12T00:00:00Z" + +const [ready, embargoHold, blocked] = evaluateAwardPublicationBatch(packets, { now }) + +assert.equal(ready.decision, "RELEASE_PUBLICATION") +assert.equal(ready.summary.blocks, 0) +assert.equal(ready.summary.reviews, 0) + +assert.equal(embargoHold.decision, "HOLD_FOR_REVIEW") +assert.ok(embargoHold.findings.some((finding) => finding.code === "embargo_active")) + +assert.equal(blocked.decision, "BLOCK_PUBLICATION") +assert.ok(blocked.findings.some((finding) => finding.code === "payout_not_authorized")) +assert.ok(blocked.findings.some((finding) => finding.code === "ip_transfer_incomplete")) +assert.ok(blocked.findings.some((finding) => finding.code === "award_splits_invalid")) +assert.ok(blocked.findings.some((finding) => finding.code === "recipient_terms_missing")) + +const anonymousPacket = structuredClone(packets[0]) +anonymousPacket.publication.namedWinners = false +anonymousPacket.award.splits[0].namedConsent = false +anonymousPacket.award.splits[1].namedConsent = false +const anonymousResult = evaluateAwardPublication(anonymousPacket, { now }) +assert.equal(anonymousResult.decision, "RELEASE_PUBLICATION") +assert.ok(anonymousResult.findings.some((finding) => finding.code === "anonymous_publication_ok")) + +const openLicensePacket = structuredClone(packets[0]) +openLicensePacket.ip = { policy: "open_license" } +const openLicenseResult = evaluateAwardPublication(openLicensePacket, { now }) +assert.equal(openLicenseResult.decision, "HOLD_FOR_REVIEW") +assert.ok(openLicenseResult.findings.some((finding) => finding.code === "open_license_missing")) + +const batchSummary = summarize([ready, embargoHold, blocked]) +assert.equal(batchSummary.blocks, blocked.summary.blocks) +assert.equal(batchSummary.reviews, embargoHold.summary.reviews + blocked.summary.reviews) + +console.log("award publication guard tests passed")