diff --git a/scientific-bounty-scope-change-guard/README.md b/scientific-bounty-scope-change-guard/README.md new file mode 100644 index 00000000..63455800 --- /dev/null +++ b/scientific-bounty-scope-change-guard/README.md @@ -0,0 +1,36 @@ +# Scientific Bounty Scope Change Guard + +Self-contained guard for SCIBASE issue #18, Scientific Bounty System. + +The module reviews sponsor-requested changes after a scientific bounty is launched. It blocks or escalates changes that would move the goalposts for solver teams: problem statements, deliverables, scoring rubrics, eligibility, prize schedules, NDA/data-room terms, and post-submission effective dates. + +## What It Checks + +- Material changes after launch are frozen unless they include sponsor, review, and audit evidence. +- Submitted or in-progress solver work is grandfathered when scope changes after submissions begin. +- Rubric weights still total 100 and changed scoring criteria have equal-notice evidence. +- Eligibility and NDA/data-room changes cannot silently narrow access for active teams. +- Prize schedule changes need finance/escrow evidence before scoring or payout. +- Active solver teams receive equal notice, and material changes require consent or a deadline extension. + +## Files + +- `index.js` - evaluation engine and report formatters +- `sample-data.js` - synthetic challenge scenarios +- `test.js` - dependency-free tests using Node's built-in `assert` +- `demo.js` - generates reviewer JSON, Markdown, and SVG reports +- `render-video.js` - renders a short MP4 with `ffmpeg`, or an animated GIF fallback with ImageMagick +- `reports/demo.mp4` / `reports/demo.gif` - reviewer demo artifact + +## Validation + +```bash +node scientific-bounty-scope-change-guard/test.js +node scientific-bounty-scope-change-guard/demo.js +node scientific-bounty-scope-change-guard/render-video.js +node --check scientific-bounty-scope-change-guard/index.js +node --check scientific-bounty-scope-change-guard/sample-data.js +node --check scientific-bounty-scope-change-guard/test.js +node --check scientific-bounty-scope-change-guard/demo.js +node --check scientific-bounty-scope-change-guard/render-video.js +``` diff --git a/scientific-bounty-scope-change-guard/demo.js b/scientific-bounty-scope-change-guard/demo.js new file mode 100644 index 00000000..690d7d43 --- /dev/null +++ b/scientific-bounty-scope-change-guard/demo.js @@ -0,0 +1,24 @@ +const fs = require('fs'); +const path = require('path'); +const { + evaluateScopeChangeControl, + formatMarkdownReport, + formatSvgSummary +} = require('./index'); +const { reviewInput } = require('./sample-data'); + +const reportsDir = path.join(__dirname, 'reports'); +fs.mkdirSync(reportsDir, { recursive: true }); + +const evaluation = evaluateScopeChangeControl(reviewInput); + +fs.writeFileSync( + path.join(reportsDir, 'scope-change-review.json'), + `${JSON.stringify(evaluation, null, 2)}\n` +); +fs.writeFileSync(path.join(reportsDir, 'scope-change-review.md'), formatMarkdownReport(evaluation)); +fs.writeFileSync(path.join(reportsDir, 'scope-change-review.svg'), formatSvgSummary(evaluation)); + +console.log(`Generated reports in ${reportsDir}`); +console.log(`Overall decision: ${evaluation.overallDecision}`); + diff --git a/scientific-bounty-scope-change-guard/index.js b/scientific-bounty-scope-change-guard/index.js new file mode 100644 index 00000000..04217f7b --- /dev/null +++ b/scientific-bounty-scope-change-guard/index.js @@ -0,0 +1,383 @@ +const crypto = require('crypto'); + +const MATERIAL_FIELDS = new Set([ + 'problemStatement', + 'deliverables', + 'rubric', + 'eligibility', + 'prizeSchedule', + 'ndaTerms', + 'dataRoomTerms' +]); + +const FIELD_LABELS = { + problemStatement: 'problem statement', + deliverables: 'deliverables', + rubric: 'scoring rubric', + eligibility: 'solver eligibility', + prizeSchedule: 'prize schedule', + ndaTerms: 'NDA terms', + dataRoomTerms: 'data-room terms' +}; + +const NOTICE_HOURS = 72; + +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 digest(value) { + return crypto.createHash('sha256').update(stableStringify(value)).digest('hex').slice(0, 16); +} + +function hoursBetween(start, end) { + return (new Date(end).getTime() - new Date(start).getTime()) / 36e5; +} + +function addFinding(findings, severity, code, message, evidence = {}) { + findings.push({ severity, code, message, evidence }); +} + +function changedMaterialFields(request) { + return Object.keys(request.changes || {}).filter((field) => MATERIAL_FIELDS.has(field)); +} + +function rubricWeightTotal(rubric = []) { + return rubric.reduce((sum, item) => sum + Number(item.weight || 0), 0); +} + +function compareRubric(previous = [], next = []) { + const previousById = new Map(previous.map((item) => [item.id, item])); + const nextById = new Map(next.map((item) => [item.id, item])); + const added = []; + const removed = []; + const changed = []; + + for (const [id, item] of nextById) { + if (!previousById.has(id)) { + added.push(id); + continue; + } + const before = previousById.get(id); + if (before.weight !== item.weight || before.maxPoints !== item.maxPoints || before.label !== item.label) { + changed.push({ + id, + before: { label: before.label, weight: before.weight, maxPoints: before.maxPoints }, + after: { label: item.label, weight: item.weight, maxPoints: item.maxPoints } + }); + } + } + + for (const id of previousById.keys()) { + if (!nextById.has(id)) { + removed.push(id); + } + } + + return { + added, + removed, + changed, + beforeTotal: rubricWeightTotal(previous), + afterTotal: rubricWeightTotal(next) + }; +} + +function activeTeams(participants = []) { + return participants.filter((team) => ['registered', 'submitted', 'finalist'].includes(team.status)); +} + +function submittedTeams(participants = []) { + return participants.filter((team) => team.submittedAt); +} + +function notificationGaps(request, participants = []) { + const notified = new Set((request.notice || {}).teamNotices || []); + return activeTeams(participants) + .filter((team) => !notified.has(team.teamId)) + .map((team) => team.teamId); +} + +function consentGaps(request, participants = []) { + const consented = new Set((request.notice || {}).teamConsents || []); + return activeTeams(participants) + .filter((team) => !consented.has(team.teamId)) + .map((team) => team.teamId); +} + +function approvalGaps(request) { + const approvals = request.approvals || {}; + const gaps = []; + if (!approvals.sponsor) gaps.push('sponsor approval'); + if (!approvals.independentReviewer) gaps.push('independent reviewer approval'); + if (!approvals.auditLog) gaps.push('immutable audit log'); + return gaps; +} + +function evaluateChangeRequest(challenge, request) { + const findings = []; + const materialFields = changedMaterialFields(request); + const launchedAt = new Date(challenge.launchedAt); + const requestedAt = new Date(request.requestedAt); + const effectiveAt = new Date(request.effectiveAt || request.requestedAt); + const afterLaunch = requestedAt >= launchedAt; + const submissions = submittedTeams(challenge.participants); + const freezeDigestBefore = digest(challenge.baseline); + const freezeDigestAfter = digest({ ...challenge.baseline, ...request.changes }); + + if (materialFields.length === 0) { + addFinding(findings, 'info', 'NON_MATERIAL_CHANGE', 'Only non-material copy or metadata fields changed.', { + changedFields: Object.keys(request.changes || {}) + }); + } + + if (afterLaunch && materialFields.length > 0) { + addFinding(findings, 'medium', 'MATERIAL_CHANGE_AFTER_LAUNCH', 'Material challenge fields changed after public launch.', { + changedFields: materialFields.map((field) => FIELD_LABELS[field] || field), + requestedAt: request.requestedAt, + launchedAt: challenge.launchedAt + }); + + const gaps = approvalGaps(request); + if (gaps.length > 0) { + addFinding(findings, 'critical', 'MISSING_CHANGE_APPROVALS', 'Post-launch material changes need sponsor, reviewer, and audit evidence.', { + missing: gaps + }); + } + } + + const missingNotice = notificationGaps(request, challenge.participants); + if (materialFields.length > 0 && missingNotice.length > 0) { + addFinding(findings, 'critical', 'UNEQUAL_SOLVER_NOTICE', 'Active solver teams were not all notified before the change takes effect.', { + teams: missingNotice + }); + } + + const noticeHours = request.notice && request.notice.sentAt ? hoursBetween(request.notice.sentAt, effectiveAt) : 0; + if (materialFields.length > 0 && noticeHours < NOTICE_HOURS) { + addFinding(findings, 'high', 'SHORT_NOTICE_WINDOW', 'Material changes need at least 72 hours notice or an explicit deadline extension.', { + noticeHours, + minimumHours: NOTICE_HOURS, + deadlineExtensionHours: request.deadlineExtensionHours || 0 + }); + } + + if (request.requiresSolverConsent) { + const missingConsent = consentGaps(request, challenge.participants); + if (missingConsent.length > 0) { + addFinding(findings, 'high', 'MISSING_SOLVER_CONSENT', 'Material scope changes requiring consent are missing active-team acknowledgements.', { + teams: missingConsent + }); + } + } + + if (submissions.length > 0 && materialFields.length > 0 && !request.grandfatherSubmittedWork) { + addFinding(findings, 'critical', 'SUBMITTED_WORK_NOT_GRANDFATHERED', 'Teams that already submitted work must be scored under the original scope or explicitly grandfathered.', { + submittedTeams: submissions.map((team) => team.teamId) + }); + } + + if (request.changes && request.changes.rubric) { + const comparison = compareRubric(challenge.baseline.rubric, request.changes.rubric); + if (comparison.afterTotal !== 100) { + addFinding(findings, 'critical', 'RUBRIC_WEIGHT_TOTAL_INVALID', 'Changed rubric weights must total 100 before scoring.', { + afterTotal: comparison.afterTotal + }); + } + if (comparison.added.length || comparison.removed.length || comparison.changed.length) { + addFinding(findings, 'medium', 'RUBRIC_CRITERIA_CHANGED', 'Rubric criteria changed and must be visible in the reviewer packet.', comparison); + } + } + + if (request.changes && request.changes.eligibility && !request.grandfatherRegisteredTeams) { + addFinding(findings, 'high', 'ELIGIBILITY_NARROWED_WITHOUT_GRANDFATHERING', 'Eligibility changes must not disqualify already registered teams without grandfathering.', { + activeTeams: activeTeams(challenge.participants).map((team) => team.teamId) + }); + } + + if (request.changes && request.changes.prizeSchedule) { + const finance = request.approvals && request.approvals.financeEscrow; + if (!finance) { + addFinding(findings, 'critical', 'PRIZE_CHANGE_WITHOUT_ESCROW_EVIDENCE', 'Prize or payout schedule changes need finance/escrow evidence before scoring proceeds.', { + prizeSchedule: request.changes.prizeSchedule + }); + } + } + + if (request.changes && (request.changes.ndaTerms || request.changes.dataRoomTerms)) { + if (!request.equalDataRoomAccess) { + addFinding(findings, 'high', 'DATA_ROOM_ACCESS_NOT_RECONFIRMED', 'NDA or data-room changes require equal re-access evidence for every active team.', { + changedFields: ['ndaTerms', 'dataRoomTerms'].filter((field) => request.changes[field]) + }); + } + } + + const hasCritical = findings.some((finding) => finding.severity === 'critical'); + const hasHigh = findings.some((finding) => finding.severity === 'high'); + const hasMedium = findings.some((finding) => finding.severity === 'medium'); + const decision = hasCritical || hasHigh ? 'hold' : hasMedium ? 'review' : 'allow'; + + return { + challengeId: challenge.challengeId, + requestId: request.id, + decision, + materialFields, + freezeDigestBefore, + freezeDigestAfter, + findingCounts: countFindings(findings), + findings, + actions: buildActions(decision, findings, request) + }; +} + +function countFindings(findings) { + return findings.reduce((counts, finding) => { + counts[finding.severity] = (counts[finding.severity] || 0) + 1; + return counts; + }, {}); +} + +function buildActions(decision, findings, request) { + if (decision === 'allow') { + return ['Record audit digest', 'Publish change packet', 'Continue normal scoring']; + } + + const actions = new Set(['Pause scoring for affected challenge', 'Publish reviewer-visible change packet']); + for (const finding of findings) { + if (finding.code === 'UNEQUAL_SOLVER_NOTICE') actions.add('Notify every active solver team'); + if (finding.code === 'MISSING_SOLVER_CONSENT') actions.add('Collect consent or extend/reopen the deadline'); + if (finding.code === 'SUBMITTED_WORK_NOT_GRANDFATHERED') actions.add('Grandfather submitted work under original scope'); + if (finding.code === 'MISSING_CHANGE_APPROVALS') actions.add('Attach sponsor, independent reviewer, and immutable audit approvals'); + if (finding.code === 'PRIZE_CHANGE_WITHOUT_ESCROW_EVIDENCE') actions.add('Attach finance escrow evidence'); + if (finding.code === 'DATA_ROOM_ACCESS_NOT_RECONFIRMED') actions.add('Reconfirm equal data-room access'); + } + + if ((request.deadlineExtensionHours || 0) < NOTICE_HOURS) { + actions.add('Add a deadline extension matching the notice window'); + } + + return Array.from(actions); +} + +function evaluateScopeChangeControl(input) { + const results = input.changeRequests.map((request) => evaluateChangeRequest(input.challenge, request)); + const decisionRank = { allow: 0, review: 1, hold: 2 }; + const overallDecision = results.reduce( + (current, result) => (decisionRank[result.decision] > decisionRank[current] ? result.decision : current), + 'allow' + ); + + return { + generatedAt: input.generatedAt || new Date().toISOString(), + challengeId: input.challenge.challengeId, + title: input.challenge.title, + overallDecision, + requestCount: results.length, + results, + reviewerPacket: buildReviewerPacket(input.challenge, results) + }; +} + +function buildReviewerPacket(challenge, results) { + return { + challengeId: challenge.challengeId, + launchedAt: challenge.launchedAt, + baselineDigest: digest(challenge.baseline), + activeTeams: activeTeams(challenge.participants).map((team) => team.teamId), + submittedTeams: submittedTeams(challenge.participants).map((team) => team.teamId), + heldRequests: results.filter((result) => result.decision === 'hold').map((result) => result.requestId), + reviewRequests: results.filter((result) => result.decision === 'review').map((result) => result.requestId), + allowRequests: results.filter((result) => result.decision === 'allow').map((result) => result.requestId) + }; +} + +function formatMarkdownReport(evaluation) { + const lines = [ + `# Scope Change Review: ${evaluation.title}`, + '', + `Overall decision: **${evaluation.overallDecision.toUpperCase()}**`, + `Challenge: \`${evaluation.challengeId}\``, + `Generated: ${evaluation.generatedAt}`, + '', + '## Requests' + ]; + + for (const result of evaluation.results) { + lines.push('', `### ${result.requestId}`, ''); + lines.push(`Decision: **${result.decision.toUpperCase()}**`); + lines.push(`Material fields: ${result.materialFields.length ? result.materialFields.join(', ') : 'none'}`); + lines.push(`Freeze digest before: \`${result.freezeDigestBefore}\``); + lines.push(`Freeze digest after: \`${result.freezeDigestAfter}\``); + lines.push(''); + lines.push('Findings:'); + for (const finding of result.findings) { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`); + } + lines.push(''); + lines.push('Actions:'); + for (const action of result.actions) { + lines.push(`- ${action}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function formatSvgSummary(evaluation) { + const colors = { + allow: '#15803d', + review: '#a16207', + hold: '#b91c1c' + }; + const rows = evaluation.results + .map((result, index) => { + const y = 170 + index * 58; + return [ + ``, + `${escapeXml(result.requestId)}`, + `${result.decision.toUpperCase()}`, + `${escapeXml(result.actions[0] || 'Record audit')}` + ].join(''); + }) + .join(''); + + return [ + '', + '', + '', + 'Scientific bounty scope-change guard', + `Overall decision: ${evaluation.overallDecision.toUpperCase()} | Requests: ${evaluation.requestCount}`, + 'Reviewer queue', + rows, + 'Synthetic data only. Blocks goalpost-moving changes before scoring or payout.', + '' + ].join('\n'); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +module.exports = { + evaluateScopeChangeControl, + evaluateChangeRequest, + compareRubric, + formatMarkdownReport, + formatSvgSummary, + stableStringify, + digest +}; + diff --git a/scientific-bounty-scope-change-guard/render-video.js b/scientific-bounty-scope-change-guard/render-video.js new file mode 100644 index 00000000..64acbf9f --- /dev/null +++ b/scientific-bounty-scope-change-guard/render-video.js @@ -0,0 +1,179 @@ +const path = require('path'); +const fs = require('fs'); +const { spawnSync } = require('child_process'); + +const output = path.join(__dirname, 'reports', 'demo.mp4'); +const gifOutput = path.join(__dirname, 'reports', 'demo.gif'); +const reportsDir = path.join(__dirname, 'reports'); + +function main() { + fs.mkdirSync(reportsDir, { recursive: true }); + const ffmpegReady = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0; + const frames = writeFrames(); + + try { + if (ffmpegReady) { + const result = spawnSync( + 'ffmpeg', + [ + '-y', + '-framerate', + '1', + '-i', + path.join(reportsDir, 'frame-%02d.ppm'), + '-vf', + 'scale=960:540:flags=neighbor,format=yuv420p', + '-movflags', + '+faststart', + '-r', + '24', + output + ], + { stdio: 'inherit' } + ); + + if (result.status === 0) { + console.log(`Rendered ${output}`); + return; + } + } + + console.log(ffmpegReady ? 'ffmpeg render failed; trying ImageMagick GIF fallback.' : 'ffmpeg is unavailable; trying ImageMagick GIF fallback.'); + const magick = spawnSync('magick', ['-delay', '140', '-loop', '0', ...frames, gifOutput], { + stdio: 'inherit' + }); + + if (magick.status !== 0) { + process.exit(magick.status || 1); + } + + console.log(`Rendered ${gifOutput}`); + } finally { + cleanupFrames(frames); + } +} + +function writeFrames() { + return [ + { + name: 'frame-01.ppm', + title: 'SCOPE GUARD', + subtitle: 'FREEZE BASELINE', + accent: '#38bdf8' + }, + { + name: 'frame-02.ppm', + title: 'RUBRIC SHIFT', + subtitle: 'PRIZE ELIGIBILITY', + accent: '#f97316' + }, + { + name: 'frame-03.ppm', + title: 'NOTICE TEAMS', + subtitle: 'CONSENT EXTEND', + accent: '#22c55e' + }, + { + name: 'frame-04.ppm', + title: 'HOLD REVIEW', + subtitle: 'ALLOW SAFE COPY', + accent: '#ef4444' + } + ].map((frame) => { + const file = path.join(reportsDir, frame.name); + fs.writeFileSync(file, ppmFrame(frame.title, frame.subtitle, frame.accent)); + return file; + }); +} + +function cleanupFrames(frames) { + for (const frame of frames) { + if (fs.existsSync(frame)) { + fs.unlinkSync(frame); + } + } +} + +function ppmFrame(title, subtitle, accent) { + const width = 480; + const height = 270; + const pixels = Buffer.alloc(width * height * 3); + fill(pixels, width, height, hex('#0f172a')); + rect(pixels, width, height, 24, 28, 10, 196, hex(accent)); + rect(pixels, width, height, 56, 142, 360, 54, hex('#1e293b')); + rect(pixels, width, height, 56, 210, 250, 10, hex(accent)); + drawText(pixels, width, height, title, 56, 58, 6, hex('#f8fafc')); + drawText(pixels, width, height, subtitle, 58, 110, 3, hex('#cbd5e1')); + drawText(pixels, width, height, 'SCIBASE ISSUE 18', 78, 160, 3, hex('#f8fafc')); + drawText(pixels, width, height, 'SYNTHETIC DEMO', 58, 236, 2, hex('#94a3b8')); + return Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), pixels]); +} + +function fill(pixels, width, height, color) { + rect(pixels, width, height, 0, 0, width, height, color); +} + +function rect(pixels, width, height, x, y, w, h, color) { + for (let row = Math.max(0, y); row < Math.min(height, y + h); row += 1) { + for (let col = Math.max(0, x); col < Math.min(width, x + w); col += 1) { + const offset = (row * width + col) * 3; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + } + } +} + +function drawText(pixels, width, height, text, x, y, scale, color) { + let cursor = x; + for (const char of text.toUpperCase()) { + if (char === ' ') { + cursor += 4 * scale; + continue; + } + const glyph = FONT[char] || FONT['?']; + for (let row = 0; row < glyph.length; row += 1) { + for (let col = 0; col < glyph[row].length; col += 1) { + if (glyph[row][col] === '1') { + rect(pixels, width, height, cursor + col * scale, y + row * scale, scale, scale, color); + } + } + } + cursor += 6 * scale; + } +} + +function hex(value) { + const clean = value.replace('#', ''); + return [0, 2, 4].map((index) => parseInt(clean.slice(index, index + 2), 16)); +} + +const FONT = { + 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', '10111', '10001', '10001', '01111'], + H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + 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'], + 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', '10001', '01010', '00100'], + W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], + Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'], + 1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], + 8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], + '?': ['11110', '00001', '00010', '00100', '00100', '00000', '00100'] +}; + +main(); diff --git a/scientific-bounty-scope-change-guard/reports/demo.gif b/scientific-bounty-scope-change-guard/reports/demo.gif new file mode 100644 index 00000000..d5aa865f Binary files /dev/null and b/scientific-bounty-scope-change-guard/reports/demo.gif differ diff --git a/scientific-bounty-scope-change-guard/reports/demo.mp4 b/scientific-bounty-scope-change-guard/reports/demo.mp4 new file mode 100644 index 00000000..f869c7f9 Binary files /dev/null and b/scientific-bounty-scope-change-guard/reports/demo.mp4 differ diff --git a/scientific-bounty-scope-change-guard/reports/scope-change-review.json b/scientific-bounty-scope-change-guard/reports/scope-change-review.json new file mode 100644 index 00000000..724e0bda --- /dev/null +++ b/scientific-bounty-scope-change-guard/reports/scope-change-review.json @@ -0,0 +1,283 @@ +{ + "generatedAt": "2026-05-31T05:58:00Z", + "challengeId": "sci-bounty-biomarker-2026", + "title": "Single-cell biomarker discovery challenge", + "overallDecision": "hold", + "requestCount": 3, + "results": [ + { + "challengeId": "sci-bounty-biomarker-2026", + "requestId": "scope-change-unsafe-rubric-and-prize", + "decision": "hold", + "materialFields": [ + "deliverables", + "rubric", + "prizeSchedule", + "eligibility" + ], + "freezeDigestBefore": "7a32fcee4f40698d", + "freezeDigestAfter": "93aee97327cc7a96", + "findingCounts": { + "medium": 2, + "critical": 5, + "high": 3 + }, + "findings": [ + { + "severity": "medium", + "code": "MATERIAL_CHANGE_AFTER_LAUNCH", + "message": "Material challenge fields changed after public launch.", + "evidence": { + "changedFields": [ + "deliverables", + "scoring rubric", + "prize schedule", + "solver eligibility" + ], + "requestedAt": "2026-05-22T09:00:00Z", + "launchedAt": "2026-05-01T12:00:00Z" + } + }, + { + "severity": "critical", + "code": "MISSING_CHANGE_APPROVALS", + "message": "Post-launch material changes need sponsor, reviewer, and audit evidence.", + "evidence": { + "missing": [ + "independent reviewer approval", + "immutable audit log" + ] + } + }, + { + "severity": "critical", + "code": "UNEQUAL_SOLVER_NOTICE", + "message": "Active solver teams were not all notified before the change takes effect.", + "evidence": { + "teams": [ + "team-beta", + "solo-gamma" + ] + } + }, + { + "severity": "high", + "code": "SHORT_NOTICE_WINDOW", + "message": "Material changes need at least 72 hours notice or an explicit deadline extension.", + "evidence": { + "noticeHours": 21, + "minimumHours": 72, + "deadlineExtensionHours": 0 + } + }, + { + "severity": "high", + "code": "MISSING_SOLVER_CONSENT", + "message": "Material scope changes requiring consent are missing active-team acknowledgements.", + "evidence": { + "teams": [ + "lab-alpha", + "team-beta", + "solo-gamma" + ] + } + }, + { + "severity": "critical", + "code": "SUBMITTED_WORK_NOT_GRANDFATHERED", + "message": "Teams that already submitted work must be scored under the original scope or explicitly grandfathered.", + "evidence": { + "submittedTeams": [ + "lab-alpha" + ] + } + }, + { + "severity": "critical", + "code": "RUBRIC_WEIGHT_TOTAL_INVALID", + "message": "Changed rubric weights must total 100 before scoring.", + "evidence": { + "afterTotal": 90 + } + }, + { + "severity": "medium", + "code": "RUBRIC_CRITERIA_CHANGED", + "message": "Rubric criteria changed and must be visible in the reviewer packet.", + "evidence": { + "added": [ + "wetlab" + ], + "removed": [ + "novelty" + ], + "changed": [ + { + "id": "accuracy", + "before": { + "label": "Scientific accuracy", + "weight": 35, + "maxPoints": 35 + }, + "after": { + "label": "Scientific accuracy", + "weight": 30, + "maxPoints": 30 + } + }, + { + "id": "reproducibility", + "before": { + "label": "Reproducibility evidence", + "weight": 30, + "maxPoints": 30 + }, + "after": { + "label": "Reproducibility evidence", + "weight": 20, + "maxPoints": 20 + } + }, + { + "id": "delivery", + "before": { + "label": "Deliverable completeness", + "weight": 15, + "maxPoints": 15 + }, + "after": { + "label": "Deliverable completeness", + "weight": 10, + "maxPoints": 10 + } + } + ], + "beforeTotal": 100, + "afterTotal": 90 + } + }, + { + "severity": "high", + "code": "ELIGIBILITY_NARROWED_WITHOUT_GRANDFATHERING", + "message": "Eligibility changes must not disqualify already registered teams without grandfathering.", + "evidence": { + "activeTeams": [ + "lab-alpha", + "team-beta", + "solo-gamma" + ] + } + }, + { + "severity": "critical", + "code": "PRIZE_CHANGE_WITHOUT_ESCROW_EVIDENCE", + "message": "Prize or payout schedule changes need finance/escrow evidence before scoring proceeds.", + "evidence": { + "prizeSchedule": { + "totalUsd": 90000, + "milestones": [ + { + "name": "winner", + "usd": 90000 + } + ] + } + } + } + ], + "actions": [ + "Pause scoring for affected challenge", + "Publish reviewer-visible change packet", + "Attach sponsor, independent reviewer, and immutable audit approvals", + "Notify every active solver team", + "Collect consent or extend/reopen the deadline", + "Grandfather submitted work under original scope", + "Attach finance escrow evidence", + "Add a deadline extension matching the notice window" + ] + }, + { + "challengeId": "sci-bounty-biomarker-2026", + "requestId": "scope-change-safe-copy-clarification", + "decision": "allow", + "materialFields": [], + "freezeDigestBefore": "7a32fcee4f40698d", + "freezeDigestAfter": "7bbf4208019cb364", + "findingCounts": { + "info": 1 + }, + "findings": [ + { + "severity": "info", + "code": "NON_MATERIAL_CHANGE", + "message": "Only non-material copy or metadata fields changed.", + "evidence": { + "changedFields": [ + "faq" + ] + } + } + ], + "actions": [ + "Record audit digest", + "Publish change packet", + "Continue normal scoring" + ] + }, + { + "challengeId": "sci-bounty-biomarker-2026", + "requestId": "scope-change-reviewed-data-room-update", + "decision": "review", + "materialFields": [ + "ndaTerms", + "dataRoomTerms" + ], + "freezeDigestBefore": "7a32fcee4f40698d", + "freezeDigestAfter": "a1cc38a8c0ddb8bb", + "findingCounts": { + "medium": 1 + }, + "findings": [ + { + "severity": "medium", + "code": "MATERIAL_CHANGE_AFTER_LAUNCH", + "message": "Material challenge fields changed after public launch.", + "evidence": { + "changedFields": [ + "NDA terms", + "data-room terms" + ], + "requestedAt": "2026-05-10T10:00:00Z", + "launchedAt": "2026-05-01T12:00:00Z" + } + } + ], + "actions": [ + "Pause scoring for affected challenge", + "Publish reviewer-visible change packet" + ] + } + ], + "reviewerPacket": { + "challengeId": "sci-bounty-biomarker-2026", + "launchedAt": "2026-05-01T12:00:00Z", + "baselineDigest": "7a32fcee4f40698d", + "activeTeams": [ + "lab-alpha", + "team-beta", + "solo-gamma" + ], + "submittedTeams": [ + "lab-alpha" + ], + "heldRequests": [ + "scope-change-unsafe-rubric-and-prize" + ], + "reviewRequests": [ + "scope-change-reviewed-data-room-update" + ], + "allowRequests": [ + "scope-change-safe-copy-clarification" + ] + } +} diff --git a/scientific-bounty-scope-change-guard/reports/scope-change-review.md b/scientific-bounty-scope-change-guard/reports/scope-change-review.md new file mode 100644 index 00000000..9285e0b5 --- /dev/null +++ b/scientific-bounty-scope-change-guard/reports/scope-change-review.md @@ -0,0 +1,65 @@ +# Scope Change Review: Single-cell biomarker discovery challenge + +Overall decision: **HOLD** +Challenge: `sci-bounty-biomarker-2026` +Generated: 2026-05-31T05:58:00Z + +## Requests + +### scope-change-unsafe-rubric-and-prize + +Decision: **HOLD** +Material fields: deliverables, rubric, prizeSchedule, eligibility +Freeze digest before: `7a32fcee4f40698d` +Freeze digest after: `93aee97327cc7a96` + +Findings: +- MEDIUM MATERIAL_CHANGE_AFTER_LAUNCH: Material challenge fields changed after public launch. +- CRITICAL MISSING_CHANGE_APPROVALS: Post-launch material changes need sponsor, reviewer, and audit evidence. +- CRITICAL UNEQUAL_SOLVER_NOTICE: Active solver teams were not all notified before the change takes effect. +- HIGH SHORT_NOTICE_WINDOW: Material changes need at least 72 hours notice or an explicit deadline extension. +- HIGH MISSING_SOLVER_CONSENT: Material scope changes requiring consent are missing active-team acknowledgements. +- CRITICAL SUBMITTED_WORK_NOT_GRANDFATHERED: Teams that already submitted work must be scored under the original scope or explicitly grandfathered. +- CRITICAL RUBRIC_WEIGHT_TOTAL_INVALID: Changed rubric weights must total 100 before scoring. +- MEDIUM RUBRIC_CRITERIA_CHANGED: Rubric criteria changed and must be visible in the reviewer packet. +- HIGH ELIGIBILITY_NARROWED_WITHOUT_GRANDFATHERING: Eligibility changes must not disqualify already registered teams without grandfathering. +- CRITICAL PRIZE_CHANGE_WITHOUT_ESCROW_EVIDENCE: Prize or payout schedule changes need finance/escrow evidence before scoring proceeds. + +Actions: +- Pause scoring for affected challenge +- Publish reviewer-visible change packet +- Attach sponsor, independent reviewer, and immutable audit approvals +- Notify every active solver team +- Collect consent or extend/reopen the deadline +- Grandfather submitted work under original scope +- Attach finance escrow evidence +- Add a deadline extension matching the notice window + +### scope-change-safe-copy-clarification + +Decision: **ALLOW** +Material fields: none +Freeze digest before: `7a32fcee4f40698d` +Freeze digest after: `7bbf4208019cb364` + +Findings: +- INFO NON_MATERIAL_CHANGE: Only non-material copy or metadata fields changed. + +Actions: +- Record audit digest +- Publish change packet +- Continue normal scoring + +### scope-change-reviewed-data-room-update + +Decision: **REVIEW** +Material fields: ndaTerms, dataRoomTerms +Freeze digest before: `7a32fcee4f40698d` +Freeze digest after: `a1cc38a8c0ddb8bb` + +Findings: +- MEDIUM MATERIAL_CHANGE_AFTER_LAUNCH: Material challenge fields changed after public launch. + +Actions: +- Pause scoring for affected challenge +- Publish reviewer-visible change packet diff --git a/scientific-bounty-scope-change-guard/reports/scope-change-review.svg b/scientific-bounty-scope-change-guard/reports/scope-change-review.svg new file mode 100644 index 00000000..70e578d7 --- /dev/null +++ b/scientific-bounty-scope-change-guard/reports/scope-change-review.svg @@ -0,0 +1,9 @@ + + + +Scientific bounty scope-change guard +Overall decision: HOLD | Requests: 3 +Reviewer queue +scope-change-unsafe-rubric-and-prizeHOLDPause scoring for affected challengescope-change-safe-copy-clarificationALLOWRecord audit digestscope-change-reviewed-data-room-updateREVIEWPause scoring for affected challenge +Synthetic data only. Blocks goalpost-moving changes before scoring or payout. + \ No newline at end of file diff --git a/scientific-bounty-scope-change-guard/sample-data.js b/scientific-bounty-scope-change-guard/sample-data.js new file mode 100644 index 00000000..de0589ab --- /dev/null +++ b/scientific-bounty-scope-change-guard/sample-data.js @@ -0,0 +1,126 @@ +const baseRubric = [ + { id: 'accuracy', label: 'Scientific accuracy', weight: 35, maxPoints: 35 }, + { id: 'reproducibility', label: 'Reproducibility evidence', weight: 30, maxPoints: 30 }, + { id: 'novelty', label: 'Novelty and impact', weight: 20, maxPoints: 20 }, + { id: 'delivery', label: 'Deliverable completeness', weight: 15, maxPoints: 15 } +]; + +const challenge = { + challengeId: 'sci-bounty-biomarker-2026', + title: 'Single-cell biomarker discovery challenge', + launchedAt: '2026-05-01T12:00:00Z', + submissionDeadline: '2026-06-01T23:59:00Z', + baseline: { + problemStatement: 'Identify robust biomarkers from the released single-cell RNA-seq cohort.', + deliverables: ['working notebook', 'ranked biomarker CSV', 'methodology memo'], + rubric: baseRubric, + eligibility: { allowedTeams: ['academic', 'independent', 'industry'], requiresPriorContribution: false }, + prizeSchedule: { totalUsd: 100000, milestones: [{ name: 'winner', usd: 80000 }, { name: 'runner-up', usd: 20000 }] }, + ndaTerms: { version: 'nda-v1', dataUse: 'challenge-only' }, + dataRoomTerms: { version: 'data-room-v1', exportAllowed: false } + }, + participants: [ + { teamId: 'lab-alpha', status: 'submitted', registeredAt: '2026-05-02T10:00:00Z', submittedAt: '2026-05-21T15:00:00Z' }, + { teamId: 'team-beta', status: 'registered', registeredAt: '2026-05-04T11:30:00Z' }, + { teamId: 'solo-gamma', status: 'registered', registeredAt: '2026-05-09T09:00:00Z' } + ] +}; + +const unsafeScopeChange = { + id: 'scope-change-unsafe-rubric-and-prize', + requestedAt: '2026-05-22T09:00:00Z', + effectiveAt: '2026-05-23T09:00:00Z', + requestedBy: 'sponsor:pharma-demo', + changes: { + deliverables: ['working notebook', 'ranked biomarker CSV', 'methodology memo', 'wet-lab validation plan'], + rubric: [ + { id: 'accuracy', label: 'Scientific accuracy', weight: 30, maxPoints: 30 }, + { id: 'reproducibility', label: 'Reproducibility evidence', weight: 20, maxPoints: 20 }, + { id: 'wetlab', label: 'Wet-lab validation readiness', weight: 30, maxPoints: 30 }, + { id: 'delivery', label: 'Deliverable completeness', weight: 10, maxPoints: 10 } + ], + prizeSchedule: { totalUsd: 90000, milestones: [{ name: 'winner', usd: 90000 }] }, + eligibility: { allowedTeams: ['industry'], requiresPriorContribution: true } + }, + approvals: { + sponsor: true, + independentReviewer: false, + auditLog: false, + financeEscrow: false + }, + notice: { + sentAt: '2026-05-22T12:00:00Z', + teamNotices: ['lab-alpha'], + teamConsents: [] + }, + requiresSolverConsent: true, + grandfatherSubmittedWork: false, + grandfatherRegisteredTeams: false, + deadlineExtensionHours: 0 +}; + +const safeClarification = { + id: 'scope-change-safe-copy-clarification', + requestedAt: '2026-05-07T09:00:00Z', + effectiveAt: '2026-05-12T09:00:00Z', + requestedBy: 'ops:challenge-admin', + changes: { + faq: 'Clarifies that CSV headers may use snake_case or camelCase.' + }, + approvals: { + sponsor: true, + independentReviewer: true, + auditLog: true, + financeEscrow: true + }, + notice: { + sentAt: '2026-05-07T09:15:00Z', + teamNotices: ['lab-alpha', 'team-beta', 'solo-gamma'], + teamConsents: ['lab-alpha', 'team-beta', 'solo-gamma'] + }, + requiresSolverConsent: false, + grandfatherSubmittedWork: true, + grandfatherRegisteredTeams: true, + deadlineExtensionHours: 0, + equalDataRoomAccess: true +}; + +const reviewedNdaUpdate = { + id: 'scope-change-reviewed-data-room-update', + requestedAt: '2026-05-10T10:00:00Z', + effectiveAt: '2026-05-16T10:00:00Z', + requestedBy: 'sponsor:data-office', + changes: { + ndaTerms: { version: 'nda-v2', dataUse: 'challenge-only', addsPublicationEmbargoDays: 30 }, + dataRoomTerms: { version: 'data-room-v2', exportAllowed: false, watermarkRequired: true } + }, + approvals: { + sponsor: true, + independentReviewer: true, + auditLog: true, + financeEscrow: true + }, + notice: { + sentAt: '2026-05-10T10:10:00Z', + teamNotices: ['lab-alpha', 'team-beta', 'solo-gamma'], + teamConsents: ['lab-alpha', 'team-beta', 'solo-gamma'] + }, + requiresSolverConsent: true, + grandfatherSubmittedWork: true, + grandfatherRegisteredTeams: true, + deadlineExtensionHours: 120, + equalDataRoomAccess: true +}; + +module.exports = { + challenge, + unsafeScopeChange, + safeClarification, + reviewedNdaUpdate, + reviewInput: { + generatedAt: '2026-05-31T05:58:00Z', + challenge, + changeRequests: [unsafeScopeChange, safeClarification, reviewedNdaUpdate] + } +}; + diff --git a/scientific-bounty-scope-change-guard/test.js b/scientific-bounty-scope-change-guard/test.js new file mode 100644 index 00000000..629eda29 --- /dev/null +++ b/scientific-bounty-scope-change-guard/test.js @@ -0,0 +1,84 @@ +const assert = require('assert'); +const { + evaluateScopeChangeControl, + evaluateChangeRequest, + compareRubric, + digest +} = require('./index'); +const { + challenge, + unsafeScopeChange, + safeClarification, + reviewedNdaUpdate, + reviewInput +} = require('./sample-data'); + +function codes(result) { + return new Set(result.findings.map((finding) => finding.code)); +} + +function testUnsafeScopeChangeIsHeld() { + const result = evaluateChangeRequest(challenge, unsafeScopeChange); + const resultCodes = codes(result); + + assert.strictEqual(result.decision, 'hold'); + assert(resultCodes.has('MISSING_CHANGE_APPROVALS')); + assert(resultCodes.has('UNEQUAL_SOLVER_NOTICE')); + assert(resultCodes.has('SUBMITTED_WORK_NOT_GRANDFATHERED')); + assert(resultCodes.has('PRIZE_CHANGE_WITHOUT_ESCROW_EVIDENCE')); + assert(result.actions.includes('Grandfather submitted work under original scope')); +} + +function testSafeClarificationIsAllowed() { + const result = evaluateChangeRequest(challenge, safeClarification); + const resultCodes = codes(result); + + assert.strictEqual(result.decision, 'allow'); + assert(resultCodes.has('NON_MATERIAL_CHANGE')); + assert(!resultCodes.has('UNEQUAL_SOLVER_NOTICE')); +} + +function testReviewedNdaUpdateIsReviewOnly() { + const result = evaluateChangeRequest(challenge, reviewedNdaUpdate); + const resultCodes = codes(result); + + assert.strictEqual(result.decision, 'review'); + assert(resultCodes.has('MATERIAL_CHANGE_AFTER_LAUNCH')); + assert(!resultCodes.has('DATA_ROOM_ACCESS_NOT_RECONFIRMED')); + assert(!resultCodes.has('MISSING_SOLVER_CONSENT')); +} + +function testRubricComparison() { + const comparison = compareRubric(challenge.baseline.rubric, unsafeScopeChange.changes.rubric); + + assert.deepStrictEqual(comparison.added, ['wetlab']); + assert.deepStrictEqual(comparison.removed, ['novelty']); + assert.strictEqual(comparison.beforeTotal, 100); + assert.strictEqual(comparison.afterTotal, 90); +} + +function testOverallPacket() { + const evaluation = evaluateScopeChangeControl(reviewInput); + + assert.strictEqual(evaluation.overallDecision, 'hold'); + assert.strictEqual(evaluation.requestCount, 3); + assert.deepStrictEqual(evaluation.reviewerPacket.heldRequests, ['scope-change-unsafe-rubric-and-prize']); + assert.deepStrictEqual(evaluation.reviewerPacket.reviewRequests, ['scope-change-reviewed-data-room-update']); + assert.strictEqual(evaluation.reviewerPacket.baselineDigest, digest(challenge.baseline)); +} + +const tests = [ + testUnsafeScopeChangeIsHeld, + testSafeClarificationIsAllowed, + testReviewedNdaUpdateIsReviewOnly, + testRubricComparison, + testOverallPacket +]; + +for (const test of tests) { + test(); + console.log(`ok - ${test.name}`); +} + +console.log(`${tests.length} tests passed`); +