diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/README.md b/scientific-bounty-system/challenge-closeout-retention-guard/README.md new file mode 100644 index 00000000..62ae82f1 --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/README.md @@ -0,0 +1,34 @@ +# Challenge Closeout Retention Guard + +This module adds a focused post-challenge closeout guard for issue #18, Scientific Bounty System. + +It validates what happens after a scientific bounty has been awarded, cancelled, or closed without award: + +- Sponsor data-room access must be revoked after the closeout deadline. +- Restricted sponsor datasets must be returned, destroyed, or retained under an explicit legal hold. +- Solver IP transfer must stay blocked until settlement is funded and recorded. +- Reviewer/arbitration records must remain retained for the appeal window. +- Private challenge outputs must be redacted before any public winner announcement. +- Cancelled or no-award challenges must preserve solver work and compensation evidence. + +The guard is dependency-free and uses synthetic records only. It emits deterministic JSON, Markdown, SVG, and MP4 artifacts for reviewer inspection. + +## Files + +- `guard.js` - closeout policy engine and report renderers. +- `data/closeout-records.json` - synthetic post-challenge closeout records. +- `demo.js` - generates JSON, Markdown, and SVG artifacts in `artifacts/`. +- `make-demo-video.js` - builds a short local MP4 demo with `ffmpeg`. +- `test.js` - dependency-free regression checks using Node's built-in `assert`. + +## Verification + +```sh +node scientific-bounty-system/challenge-closeout-retention-guard/test.js +node scientific-bounty-system/challenge-closeout-retention-guard/demo.js +node scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js +``` + +## Scope Boundary + +This is not another intake, rubric-readiness, scoring, arbitration, payout eligibility, sponsor scorecard, IP-redaction preview, data-room access grant, cancellation/no-award, or solver-withdrawal module. It focuses on the final closeout boundary after solver work has ended: revocation, destruction/return evidence, appeal-record retention, settlement-gated IP transfer, and public disclosure readiness. diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.json b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.json new file mode 100644 index 00000000..197080a0 --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.json @@ -0,0 +1,143 @@ +{ + "generatedBy": "challenge-closeout-retention-guard", + "packetId": "challenge-closeout-demo-001", + "generatedAt": "2026-05-28T00:00:00Z", + "decision": "hold-closeout", + "riskScore": 32, + "severityCounts": { + "critical": 2, + "high": 6, + "medium": 2, + "low": 0 + }, + "challenges": [ + { + "challengeId": "climate-model-prize", + "challengeTitle": "Regional climate downscaling model prize", + "outcome": "awarded", + "daysSinceClose": 8 + }, + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "outcome": "awarded", + "daysSinceClose": 14 + }, + { + "challengeId": "quantum-noise-cancelled", + "challengeTitle": "Quantum noise mitigation prototype", + "outcome": "cancelled", + "daysSinceClose": 3 + } + ], + "findings": [ + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "code": "active-data-room-access-after-closeout", + "severity": "critical", + "domain": "access revocation", + "evidence": "solver-team-beta still has solver access to sponsor-rnaseq-training-cohort 14 days after closeout; policy requires revocation within 2 days.", + "action": "Revoke access, rotate shared credentials, and record the revocation event before closeout is accepted.", + "closeoutHold": true + }, + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "code": "restricted-data-disposition-missing", + "severity": "high", + "domain": "data retention", + "evidence": "sponsor-rnaseq-training-cohort is restricted and has no destruction, return, or legal-hold disposition.", + "action": "Collect a destruction certificate, return receipt, or legal-hold order for the restricted dataset.", + "closeoutHold": true + }, + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "code": "destruction-evidence-missing", + "severity": "high", + "domain": "data retention", + "evidence": "sponsor-clinical-labels is marked destroyed without a certificate or audit evidence identifier.", + "action": "Attach the destruction certificate hash or reviewer-verifiable evidence identifier.", + "closeoutHold": true + }, + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "code": "ip-transferred-before-funded-settlement", + "severity": "critical", + "domain": "IP transfer", + "evidence": "IP status is transferred while settlement status is pending.", + "action": "Reverse the transfer state or block sponsor use until funded settlement is recorded.", + "closeoutHold": true + }, + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "code": "appeal-record-retention-too-short", + "severity": "high", + "domain": "appeal records", + "evidence": "Appeal records are retained for 32 days, below the 60-day policy.", + "action": "Extend arbitration and appeal record retention before deleting review evidence.", + "closeoutHold": true + }, + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "code": "private-challenge-disclosure-unredacted", + "severity": "high", + "domain": "public disclosure", + "evidence": "Public disclosure is enabled for a private challenge without approved redactions.", + "action": "Block announcement until sponsor data, solver trade secrets, and reviewer identities are redacted.", + "closeoutHold": true + }, + { + "challengeId": "oncology-biomarker-private", + "challengeTitle": "Private oncology biomarker discovery challenge", + "code": "closeout-audit-digest-unsigned", + "severity": "medium", + "domain": "audit evidence", + "evidence": "Closeout audit digest status is draft.", + "action": "Sign the closeout digest after revocation, retention, and settlement checks complete.", + "closeoutHold": false + }, + { + "challengeId": "quantum-noise-cancelled", + "challengeTitle": "Quantum noise mitigation prototype", + "code": "cancelled-challenge-compensation-missing", + "severity": "high", + "domain": "solver protection", + "evidence": "The challenge was cancelled after work started, but no partial-compensation decision is recorded.", + "action": "Record partial compensation, refund, or no-compensation rationale before final closeout.", + "closeoutHold": true + }, + { + "challengeId": "quantum-noise-cancelled", + "challengeTitle": "Quantum noise mitigation prototype", + "code": "appeal-record-retention-too-short", + "severity": "high", + "domain": "appeal records", + "evidence": "Appeal records are retained for 11 days, below the 30-day policy.", + "action": "Extend arbitration and appeal record retention before deleting review evidence.", + "closeoutHold": true + }, + { + "challengeId": "quantum-noise-cancelled", + "challengeTitle": "Quantum noise mitigation prototype", + "code": "closeout-audit-digest-unsigned", + "severity": "medium", + "domain": "audit evidence", + "evidence": "Closeout audit digest status is unsigned.", + "action": "Sign the closeout digest after revocation, retention, and settlement checks complete.", + "closeoutHold": false + } + ], + "closeoutActions": [ + "Approve redactions before publishing private-challenge outcomes.", + "Collect destruction, return, or legal-hold evidence for restricted sponsor datasets.", + "Gate IP transfer and sponsor use until funded settlement is recorded.", + "Publish solver-facing closeout rationale and compensation decisions.", + "Retain arbitration records through the policy appeal window.", + "Revoke stale data-room and reviewer access, then rotate shared challenge credentials." + ] +} diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.md b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.md new file mode 100644 index 00000000..5c57aa1a --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-report.md @@ -0,0 +1,82 @@ +# Challenge Closeout Retention Guard + +Decision: hold-closeout +Risk score: 32 + +## Severity Counts + +| Severity | Count | +| --- | ---: | +| critical | 2 | +| high | 6 | +| medium | 2 | +| low | 0 | + +## Challenge Summary + +| Challenge | Outcome | Days Since Close | +| --- | --- | ---: | +| Regional climate downscaling model prize | awarded | 8 | +| Private oncology biomarker discovery challenge | awarded | 14 | +| Quantum noise mitigation prototype | cancelled | 3 | + +## Findings + +### CRITICAL: active-data-room-access-after-closeout +Challenge: Private oncology biomarker discovery challenge +Evidence: solver-team-beta still has solver access to sponsor-rnaseq-training-cohort 14 days after closeout; policy requires revocation within 2 days. +Action: Revoke access, rotate shared credentials, and record the revocation event before closeout is accepted. + +### HIGH: restricted-data-disposition-missing +Challenge: Private oncology biomarker discovery challenge +Evidence: sponsor-rnaseq-training-cohort is restricted and has no destruction, return, or legal-hold disposition. +Action: Collect a destruction certificate, return receipt, or legal-hold order for the restricted dataset. + +### HIGH: destruction-evidence-missing +Challenge: Private oncology biomarker discovery challenge +Evidence: sponsor-clinical-labels is marked destroyed without a certificate or audit evidence identifier. +Action: Attach the destruction certificate hash or reviewer-verifiable evidence identifier. + +### CRITICAL: ip-transferred-before-funded-settlement +Challenge: Private oncology biomarker discovery challenge +Evidence: IP status is transferred while settlement status is pending. +Action: Reverse the transfer state or block sponsor use until funded settlement is recorded. + +### HIGH: appeal-record-retention-too-short +Challenge: Private oncology biomarker discovery challenge +Evidence: Appeal records are retained for 32 days, below the 60-day policy. +Action: Extend arbitration and appeal record retention before deleting review evidence. + +### HIGH: private-challenge-disclosure-unredacted +Challenge: Private oncology biomarker discovery challenge +Evidence: Public disclosure is enabled for a private challenge without approved redactions. +Action: Block announcement until sponsor data, solver trade secrets, and reviewer identities are redacted. + +### MEDIUM: closeout-audit-digest-unsigned +Challenge: Private oncology biomarker discovery challenge +Evidence: Closeout audit digest status is draft. +Action: Sign the closeout digest after revocation, retention, and settlement checks complete. + +### HIGH: cancelled-challenge-compensation-missing +Challenge: Quantum noise mitigation prototype +Evidence: The challenge was cancelled after work started, but no partial-compensation decision is recorded. +Action: Record partial compensation, refund, or no-compensation rationale before final closeout. + +### HIGH: appeal-record-retention-too-short +Challenge: Quantum noise mitigation prototype +Evidence: Appeal records are retained for 11 days, below the 30-day policy. +Action: Extend arbitration and appeal record retention before deleting review evidence. + +### MEDIUM: closeout-audit-digest-unsigned +Challenge: Quantum noise mitigation prototype +Evidence: Closeout audit digest status is unsigned. +Action: Sign the closeout digest after revocation, retention, and settlement checks complete. + +## Closeout Actions + +- Approve redactions before publishing private-challenge outcomes. +- Collect destruction, return, or legal-hold evidence for restricted sponsor datasets. +- Gate IP transfer and sponsor use until funded settlement is recorded. +- Publish solver-facing closeout rationale and compensation decisions. +- Retain arbitration records through the policy appeal window. +- Revoke stale data-room and reviewer access, then rotate shared challenge credentials. diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-summary.svg b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-summary.svg new file mode 100644 index 00000000..7b292cdd --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/closeout-summary.svg @@ -0,0 +1,11 @@ + + + +Challenge Closeout Guard +Decision: hold-closeout +critical2 +high6 +medium2 +low0 +Risk score: 32 | Challenges: 3 | Findings: 10 + diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/demo.mp4 b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/demo.mp4 new file mode 100644 index 00000000..2cb4aefd Binary files /dev/null and b/scientific-bounty-system/challenge-closeout-retention-guard/artifacts/demo.mp4 differ diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/data/closeout-records.json b/scientific-bounty-system/challenge-closeout-retention-guard/data/closeout-records.json new file mode 100644 index 00000000..21a775a3 --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/data/closeout-records.json @@ -0,0 +1,150 @@ +{ + "packetId": "challenge-closeout-demo-001", + "generatedAt": "2026-05-28T00:00:00Z", + "challenges": [ + { + "id": "climate-model-prize", + "title": "Regional climate downscaling model prize", + "closeout": { + "outcome": "awarded", + "closedAt": "2026-05-20T00:00:00Z" + }, + "policy": { + "revokeAccessWithinDays": 3, + "retainAppealRecordsDays": 45 + }, + "dataGrants": [ + { + "actor": "solver-team-alpha", + "role": "solver", + "datasetId": "sponsor-weather-stations-v4", + "classification": "restricted", + "active": false, + "closeoutDisposition": "destroyed", + "evidenceId": "cert-8841" + }, + { + "actor": "external-reviewer-2", + "role": "reviewer", + "datasetId": "public-baseline-archive", + "classification": "public", + "active": false, + "closeoutDisposition": "retained", + "evidenceId": "audit-9910" + } + ], + "settlement": { + "status": "funded" + }, + "ipTransfer": { + "status": "transferred" + }, + "solverProtection": { + "partialCompensationDecision": "not-needed", + "noAwardReasonPublished": true + }, + "records": { + "appealRecordsRetainUntil": "2026-07-10T00:00:00Z", + "auditDigestStatus": "signed" + }, + "publicDisclosure": { + "enabled": true, + "privateChallenge": false, + "redactionApproved": true + } + }, + { + "id": "oncology-biomarker-private", + "title": "Private oncology biomarker discovery challenge", + "closeout": { + "outcome": "awarded", + "closedAt": "2026-05-14T00:00:00Z" + }, + "policy": { + "revokeAccessWithinDays": 2, + "retainAppealRecordsDays": 60 + }, + "dataGrants": [ + { + "actor": "solver-team-beta", + "role": "solver", + "datasetId": "sponsor-rnaseq-training-cohort", + "classification": "restricted", + "active": true, + "closeoutDisposition": "unresolved", + "evidenceId": null + }, + { + "actor": "reviewer-panel-a", + "role": "reviewer", + "datasetId": "sponsor-clinical-labels", + "classification": "restricted", + "active": false, + "closeoutDisposition": "destroyed", + "evidenceId": null + } + ], + "settlement": { + "status": "pending" + }, + "ipTransfer": { + "status": "transferred" + }, + "solverProtection": { + "partialCompensationDecision": "not-needed", + "noAwardReasonPublished": true + }, + "records": { + "appealRecordsRetainUntil": "2026-06-15T00:00:00Z", + "auditDigestStatus": "draft" + }, + "publicDisclosure": { + "enabled": true, + "privateChallenge": true, + "redactionApproved": false + } + }, + { + "id": "quantum-noise-cancelled", + "title": "Quantum noise mitigation prototype", + "closeout": { + "outcome": "cancelled", + "closedAt": "2026-05-25T00:00:00Z" + }, + "policy": { + "revokeAccessWithinDays": 5, + "retainAppealRecordsDays": 30 + }, + "dataGrants": [ + { + "actor": "solver-lab-gamma", + "role": "solver", + "datasetId": "public-noise-fixtures", + "classification": "public", + "active": false, + "closeoutDisposition": "retained", + "evidenceId": "audit-2237" + } + ], + "settlement": { + "status": "refund-pending" + }, + "ipTransfer": { + "status": "blocked" + }, + "solverProtection": { + "partialCompensationDecision": null, + "noAwardReasonPublished": false + }, + "records": { + "appealRecordsRetainUntil": "2026-06-05T00:00:00Z", + "auditDigestStatus": "unsigned" + }, + "publicDisclosure": { + "enabled": false, + "privateChallenge": false, + "redactionApproved": false + } + } + ] +} diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/demo.js b/scientific-bounty-system/challenge-closeout-retention-guard/demo.js new file mode 100644 index 00000000..3724bb75 --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/demo.js @@ -0,0 +1,25 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + analyzeCloseoutRecords, + renderMarkdown, + renderSvg +} = require('./guard'); + +const root = __dirname; +const packetPath = path.join(root, 'data', 'closeout-records.json'); +const artifactsDir = path.join(root, 'artifacts'); +const packet = JSON.parse(fs.readFileSync(packetPath, 'utf8')); +const report = analyzeCloseoutRecords(packet); + +fs.mkdirSync(artifactsDir, { recursive: true }); +fs.writeFileSync(path.join(artifactsDir, 'closeout-report.json'), `${JSON.stringify(report, null, 2)}\n`); +fs.writeFileSync(path.join(artifactsDir, 'closeout-report.md'), renderMarkdown(report)); +fs.writeFileSync(path.join(artifactsDir, 'closeout-summary.svg'), renderSvg(report)); + +console.log(`decision=${report.decision}`); +console.log(`riskScore=${report.riskScore}`); +console.log(`findings=${report.findings.length}`); +console.log(`artifacts=${artifactsDir}`); diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/guard.js b/scientific-bounty-system/challenge-closeout-retention-guard/guard.js new file mode 100644 index 00000000..adab3a51 --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/guard.js @@ -0,0 +1,272 @@ +'use strict'; + +const severityWeight = { + critical: 5, + high: 3, + medium: 2, + low: 1 +}; + +const severityOrder = ['critical', 'high', 'medium', 'low']; + +function daysBetween(start, end) { + const msPerDay = 24 * 60 * 60 * 1000; + return Math.round((new Date(end).getTime() - new Date(start).getTime()) / msPerDay); +} + +function addFinding(findings, challenge, code, severity, domain, evidence, action) { + findings.push({ + challengeId: challenge.id, + challengeTitle: challenge.title, + code, + severity, + domain, + evidence, + action, + closeoutHold: severity === 'critical' || severity === 'high' + }); +} + +function evaluateChallenge(challenge, now = '2026-05-28T00:00:00Z') { + const findings = []; + const daysSinceClose = daysBetween(challenge.closeout.closedAt, now); + const revokeDeadlineDays = challenge.policy.revokeAccessWithinDays; + const retainAppealDays = challenge.policy.retainAppealRecordsDays; + + for (const grant of challenge.dataGrants) { + if (grant.active && daysSinceClose > revokeDeadlineDays) { + addFinding( + findings, + challenge, + 'active-data-room-access-after-closeout', + 'critical', + 'access revocation', + `${grant.actor} still has ${grant.role} access to ${grant.datasetId} ${daysSinceClose} days after closeout; policy requires revocation within ${revokeDeadlineDays} days.`, + 'Revoke access, rotate shared credentials, and record the revocation event before closeout is accepted.' + ); + } + + if (grant.classification === 'restricted' && grant.closeoutDisposition === 'unresolved') { + addFinding( + findings, + challenge, + 'restricted-data-disposition-missing', + 'high', + 'data retention', + `${grant.datasetId} is restricted and has no destruction, return, or legal-hold disposition.`, + 'Collect a destruction certificate, return receipt, or legal-hold order for the restricted dataset.' + ); + } + + if (grant.closeoutDisposition === 'destroyed' && !grant.evidenceId) { + addFinding( + findings, + challenge, + 'destruction-evidence-missing', + 'high', + 'data retention', + `${grant.datasetId} is marked destroyed without a certificate or audit evidence identifier.`, + 'Attach the destruction certificate hash or reviewer-verifiable evidence identifier.' + ); + } + } + + if (challenge.settlement.status !== 'funded' && challenge.ipTransfer.status === 'transferred') { + addFinding( + findings, + challenge, + 'ip-transferred-before-funded-settlement', + 'critical', + 'IP transfer', + `IP status is transferred while settlement status is ${challenge.settlement.status}.`, + 'Reverse the transfer state or block sponsor use until funded settlement is recorded.' + ); + } + + if (challenge.closeout.outcome === 'cancelled' && !challenge.solverProtection.partialCompensationDecision) { + addFinding( + findings, + challenge, + 'cancelled-challenge-compensation-missing', + 'high', + 'solver protection', + 'The challenge was cancelled after work started, but no partial-compensation decision is recorded.', + 'Record partial compensation, refund, or no-compensation rationale before final closeout.' + ); + } + + if (challenge.closeout.outcome === 'no-award' && !challenge.solverProtection.noAwardReasonPublished) { + addFinding( + findings, + challenge, + 'no-award-reason-not-published', + 'medium', + 'solver protection', + 'No-award closeout does not include a solver-visible reason packet.', + 'Publish a solver-facing reason packet that preserves private sponsor data.' + ); + } + + const appealAge = daysBetween(challenge.closeout.closedAt, challenge.records.appealRecordsRetainUntil); + if (appealAge < retainAppealDays) { + addFinding( + findings, + challenge, + 'appeal-record-retention-too-short', + 'high', + 'appeal records', + `Appeal records are retained for ${appealAge} days, below the ${retainAppealDays}-day policy.`, + 'Extend arbitration and appeal record retention before deleting review evidence.' + ); + } + + if (challenge.publicDisclosure.enabled && challenge.publicDisclosure.privateChallenge && !challenge.publicDisclosure.redactionApproved) { + addFinding( + findings, + challenge, + 'private-challenge-disclosure-unredacted', + 'high', + 'public disclosure', + 'Public disclosure is enabled for a private challenge without approved redactions.', + 'Block announcement until sponsor data, solver trade secrets, and reviewer identities are redacted.' + ); + } + + if (challenge.records.auditDigestStatus !== 'signed') { + addFinding( + findings, + challenge, + 'closeout-audit-digest-unsigned', + 'medium', + 'audit evidence', + `Closeout audit digest status is ${challenge.records.auditDigestStatus}.`, + 'Sign the closeout digest after revocation, retention, and settlement checks complete.' + ); + } + + return { + challengeId: challenge.id, + challengeTitle: challenge.title, + outcome: challenge.closeout.outcome, + daysSinceClose, + findings + }; +} + +function summarizeFindings(findings) { + return severityOrder.reduce((summary, severity) => { + summary[severity] = findings.filter((finding) => finding.severity === severity).length; + return summary; + }, {}); +} + +function buildCloseoutActions(findings) { + const actions = new Set(); + + for (const finding of findings) { + if (finding.domain === 'access revocation') actions.add('Revoke stale data-room and reviewer access, then rotate shared challenge credentials.'); + if (finding.domain === 'data retention') actions.add('Collect destruction, return, or legal-hold evidence for restricted sponsor datasets.'); + if (finding.domain === 'IP transfer') actions.add('Gate IP transfer and sponsor use until funded settlement is recorded.'); + if (finding.domain === 'solver protection') actions.add('Publish solver-facing closeout rationale and compensation decisions.'); + if (finding.domain === 'appeal records') actions.add('Retain arbitration records through the policy appeal window.'); + if (finding.domain === 'public disclosure') actions.add('Approve redactions before publishing private-challenge outcomes.'); + } + + return [...actions].sort(); +} + +function analyzeCloseoutRecords(packet) { + const challenges = packet.challenges.map((challenge) => evaluateChallenge(challenge, packet.generatedAt)); + const findings = challenges.flatMap((challenge) => challenge.findings); + const riskScore = findings.reduce((score, finding) => score + severityWeight[finding.severity], 0); + const decision = findings.some((finding) => finding.closeoutHold) + ? 'hold-closeout' + : 'closeout-ready'; + + return { + generatedBy: 'challenge-closeout-retention-guard', + packetId: packet.packetId, + generatedAt: packet.generatedAt, + decision, + riskScore, + severityCounts: summarizeFindings(findings), + challenges: challenges.map(({ findings: _findings, ...challenge }) => challenge), + findings, + closeoutActions: buildCloseoutActions(findings) + }; +} + +function renderMarkdown(report) { + const lines = [ + '# Challenge Closeout Retention Guard', + '', + `Decision: ${report.decision}`, + `Risk score: ${report.riskScore}`, + '', + '## Severity Counts', + '', + '| Severity | Count |', + '| --- | ---: |', + ...severityOrder.map((severity) => `| ${severity} | ${report.severityCounts[severity]} |`), + '', + '## Challenge Summary', + '', + '| Challenge | Outcome | Days Since Close |', + '| --- | --- | ---: |', + ...report.challenges.map((challenge) => `| ${challenge.challengeTitle} | ${challenge.outcome} | ${challenge.daysSinceClose} |`), + '', + '## Findings', + '' + ]; + + for (const finding of report.findings) { + lines.push(`### ${finding.severity.toUpperCase()}: ${finding.code}`); + lines.push(`Challenge: ${finding.challengeTitle}`); + lines.push(`Evidence: ${finding.evidence}`); + lines.push(`Action: ${finding.action}`); + lines.push(''); + } + + lines.push('## Closeout Actions'); + lines.push(''); + for (const action of report.closeoutActions) { + lines.push(`- ${action}`); + } + + return `${lines.join('\n')}\n`; +} + +function renderSvg(report) { + const total = Math.max(1, report.findings.length); + const colors = { + critical: '#991b1b', + high: '#dc2626', + medium: '#f59e0b', + low: '#2563eb' + }; + const bars = severityOrder.map((severity, index) => { + const count = report.severityCounts[severity]; + const width = 30 + Math.round((count / total) * 300); + const y = 92 + index * 42; + return `${severity}${count}`; + }).join('\n'); + + return [ + '', + '', + '', + 'Challenge Closeout Guard', + `Decision: ${report.decision}`, + bars, + `Risk score: ${report.riskScore} | Challenges: ${report.challenges.length} | Findings: ${report.findings.length}`, + '', + '' + ].join('\n'); +} + +module.exports = { + analyzeCloseoutRecords, + evaluateChallenge, + renderMarkdown, + renderSvg +}; diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js b/scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js new file mode 100644 index 00000000..3dab7742 --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/make-demo-video.js @@ -0,0 +1,167 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const width = 640; +const height = 360; +const fps = 12; +const frames = 60; +const colors = { + bg: [248, 250, 252], + card: [255, 255, 255], + border: [203, 213, 225], + ink: [15, 23, 42], + muted: [71, 85, 105], + critical: [153, 27, 27], + high: [220, 38, 38], + medium: [245, 158, 11], + low: [37, 99, 235] +}; + +const glyphs = { + ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], + '-': ['00000', '00000', '00000', '11110', '00000', '00000', '00000'], + '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], + '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], + '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], + '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'], + '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], + '5': ['11111', '10000', '10000', '11110', '00001', '00001', '11110'], + '6': ['01110', '10000', '10000', '11110', '10001', '10001', '01110'], + '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], + '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], + '9': ['01110', '10001', '10001', '01111', '00001', '00001', '01110'], + A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'], + D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + G: ['01111', '10000', '10000', '10011', '10001', '10001', '01111'], + H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'], + K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], + N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], + O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], + P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], + R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], + V: ['10001', '10001', '10001', '10001', '01010', '01010', '00100'], + W: ['10001', '10001', '10001', '10101', '10101', '11011', '10001'], + X: ['10001', '01010', '00100', '00100', '01010', '10001', '10001'], + Y: ['10001', '01010', '00100', '00100', '00100', '00100', '00100'], + Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'] +}; + +function setPixel(buffer, x, y, color) { + if (x < 0 || y < 0 || x >= width || y >= height) return; + const index = (y * width + x) * 3; + buffer[index] = color[0]; + buffer[index + 1] = color[1]; + buffer[index + 2] = color[2]; +} + +function rect(buffer, x, y, w, h, color) { + for (let py = Math.max(0, y); py < Math.min(height, y + h); py++) { + for (let px = Math.max(0, x); px < Math.min(width, x + w); px++) { + setPixel(buffer, px, py, color); + } + } +} + +function fill(buffer, color) { + for (let i = 0; i < buffer.length; i += 3) { + buffer[i] = color[0]; + buffer[i + 1] = color[1]; + buffer[i + 2] = color[2]; + } +} + +function drawText(buffer, text, x, y, scale, color) { + let cursor = x; + for (const char of text.toUpperCase()) { + const pattern = glyphs[char] || glyphs[' ']; + for (let row = 0; row < pattern.length; row++) { + for (let col = 0; col < pattern[row].length; col++) { + if (pattern[row][col] === '1') { + rect(buffer, cursor + col * scale, y + row * scale, scale, scale, color); + } + } + } + cursor += 6 * scale; + } +} + +function writeFrame(filePath, buffer) { + fs.writeFileSync(filePath, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), Buffer.from(buffer)])); +} + +function renderFrame(index, outputDir) { + const buffer = Buffer.alloc(width * height * 3); + const progress = (index + 1) / frames; + fill(buffer, colors.bg); + rect(buffer, 34, 30, 572, 300, colors.card); + rect(buffer, 34, 30, 572, 3, colors.border); + rect(buffer, 34, 327, 572, 3, colors.border); + rect(buffer, 34, 30, 3, 300, colors.border); + rect(buffer, 603, 30, 3, 300, colors.border); + + drawText(buffer, 'CLOSEOUT GUARD', 58, 58, 4, colors.ink); + drawText(buffer, 'HOLD CLOSEOUT', 58, 104, 3, colors.muted); + drawText(buffer, 'RISK SCORE 32', 58, 140, 3, colors.ink); + + const scale = Math.min(1, progress * 1.3); + drawText(buffer, 'CRITICAL 2', 58, 188, 3, colors.ink); + rect(buffer, 236, 186, Math.round(230 * scale), 26, colors.critical); + drawText(buffer, 'HIGH 6', 58, 226, 3, colors.ink); + rect(buffer, 236, 224, Math.round(205 * Math.max(0, progress - 0.15) * 1.35), 26, colors.high); + drawText(buffer, 'MEDIUM 2', 58, 264, 3, colors.ink); + rect(buffer, 236, 262, Math.round(120 * Math.max(0, progress - 0.3) * 1.6), 26, colors.medium); + + rect(buffer, 430, 105, 95, 95, colors.border); + rect(buffer, 454, 129, 47, 47, index % 16 < 8 ? colors.high : colors.critical); + drawText(buffer, 'REVOKE', 414, 218, 2, colors.muted); + drawText(buffer, 'RETAIN', 414, 252, 2, colors.muted); + + writeFrame(path.join(outputDir, `frame${String(index).padStart(3, '0')}.ppm`), buffer); +} + +function main() { + const root = __dirname; + const artifactsDir = path.join(root, 'artifacts'); + const outputPath = path.join(artifactsDir, 'demo.mp4'); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'closeout-demo-')); + fs.mkdirSync(artifactsDir, { recursive: true }); + + for (let index = 0; index < frames; index++) renderFrame(index, tempDir); + + const result = spawnSync('ffmpeg', [ + '-y', + '-framerate', + String(fps), + '-i', + path.join(tempDir, 'frame%03d.ppm'), + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + '-movflags', + '+faststart', + outputPath + ], { stdio: 'inherit' }); + + fs.rmSync(tempDir, { recursive: true, force: true }); + if (result.status !== 0) throw new Error('ffmpeg failed to create demo.mp4'); + console.log(outputPath); +} + +main(); diff --git a/scientific-bounty-system/challenge-closeout-retention-guard/test.js b/scientific-bounty-system/challenge-closeout-retention-guard/test.js new file mode 100644 index 00000000..40354213 --- /dev/null +++ b/scientific-bounty-system/challenge-closeout-retention-guard/test.js @@ -0,0 +1,49 @@ +'use strict'; + +const assert = require('assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { + analyzeCloseoutRecords, + evaluateChallenge, + renderMarkdown, + renderSvg +} = require('./guard'); + +const packetPath = path.join(__dirname, 'data', 'closeout-records.json'); +const packet = JSON.parse(fs.readFileSync(packetPath, 'utf8')); +const report = analyzeCloseoutRecords(packet); + +assert.equal(report.generatedBy, 'challenge-closeout-retention-guard'); +assert.equal(report.packetId, 'challenge-closeout-demo-001'); +assert.equal(report.decision, 'hold-closeout'); +assert.equal(report.challenges.length, 3); +assert.ok(report.riskScore >= 25); +assert.ok(report.severityCounts.critical >= 2); +assert.ok(report.severityCounts.high >= 4); + +const oncologyFindings = report.findings.filter((finding) => finding.challengeId === 'oncology-biomarker-private'); +const oncologyCodes = new Set(oncologyFindings.map((finding) => finding.code)); +assert.ok(oncologyCodes.has('active-data-room-access-after-closeout')); +assert.ok(oncologyCodes.has('restricted-data-disposition-missing')); +assert.ok(oncologyCodes.has('destruction-evidence-missing')); +assert.ok(oncologyCodes.has('ip-transferred-before-funded-settlement')); +assert.ok(oncologyCodes.has('private-challenge-disclosure-unredacted')); + +const cleanChallenge = packet.challenges.find((challenge) => challenge.id === 'climate-model-prize'); +assert.deepEqual(evaluateChallenge(cleanChallenge, packet.generatedAt).findings, []); + +assert.ok(report.closeoutActions.some((action) => action.includes('Revoke stale data-room'))); +assert.ok(report.closeoutActions.some((action) => action.includes('Gate IP transfer'))); + +const markdown = renderMarkdown(report); +assert.ok(markdown.includes('Challenge Closeout Retention Guard')); +assert.ok(markdown.includes('active-data-room-access-after-closeout')); +assert.ok(markdown.includes('| critical |')); + +const svg = renderSvg(report); +assert.ok(svg.startsWith('