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 [
+ ''
+ ].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 @@
+
\ 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`);
+