diff --git a/README.md b/README.md index d338cf6..e08feca 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # deepevents.ai deepevents.ai main codebase + +## Scientific bounty additions + +- `scientific-bounty-amendment-control`: sponsor amendment control ledger for scientific bounty changes after solvers start, including impact findings, solver notification packets, evaluation holds, and signed audit evidence. diff --git a/scientific-bounty-amendment-control/README.md b/scientific-bounty-amendment-control/README.md new file mode 100644 index 0000000..bec3ae3 --- /dev/null +++ b/scientific-bounty-amendment-control/README.md @@ -0,0 +1,29 @@ +# Scientific Bounty Amendment Control + +This module implements a focused milestone for SCIBASE issue #18, the Scientific Bounty System. It models sponsor-side challenge amendments after solvers have started, then produces deterministic safety evidence for reviewer and admin workflows. + +The slice is intentionally narrow: it does not duplicate challenge intake, scoring, arbitration, escrow settlement, appeals, workspace privacy, anti-collusion, or reproducibility-audit submissions. It covers the change-control gap between a published challenge and final evaluation. + +## What It Does + +- Detects material amendments after registrations, submissions, or reviewer assignments begin. +- Classifies deadline, prize, rubric, deliverable, NDA, IP policy, and visibility changes by severity. +- Builds solver notification packets with acknowledgement state and notice-window coverage. +- Places evaluation, reviewer assignment, or payout-readiness holds when fairness rules are not met. +- Emits signed audit events and a deterministic digest for sponsor, solver, and moderator review. + +## Run Locally + +```bash +npm run check +npm test +npm run demo +npm run demo:gif +``` + +## Demo Artifacts + +- `docs/demo.svg` is a static dashboard preview. +- `docs/demo.gif` is generated by `npm run demo:gif` and shows three review states. + +The implementation uses synthetic data only. It does not connect to payment rails, accept platform terms, or perform any live payout or identity action. diff --git a/scientific-bounty-amendment-control/data/sample-amendments.json b/scientific-bounty-amendment-control/data/sample-amendments.json new file mode 100644 index 0000000..b28cda3 --- /dev/null +++ b/scientific-bounty-amendment-control/data/sample-amendments.json @@ -0,0 +1,115 @@ +{ + "generatedAt": "2026-05-16T15:05:00.000Z", + "challenge": { + "id": "challenge-bio-marker-042", + "title": "Single-cell biomarker discovery challenge", + "status": "published", + "registrationOpenedAt": "2026-05-01T09:00:00.000Z", + "submissionOpenedAt": "2026-05-10T09:00:00.000Z", + "submissionDueAt": "2026-05-28T23:59:00.000Z", + "reviewStartsAt": "2026-05-29T12:00:00.000Z", + "prizePoolUsd": 42000, + "ipPolicy": "solver_retains_until_paid", + "visibility": "named_finalists", + "requiredDeliverables": ["model-card", "reproducible-notebook", "ranked-biomarkers", "license-attestation"], + "rubric": { + "accuracy": 40, + "reproducibility": 25, + "biological-plausibility": 20, + "documentation": 15 + } + }, + "teams": [ + { + "id": "team-atlas", + "name": "Atlas Cell Lab", + "registeredAt": "2026-05-02T12:00:00.000Z", + "submissionStartedAt": "2026-05-11T18:20:00.000Z", + "lastActiveAt": "2026-05-15T17:30:00.000Z" + }, + { + "id": "team-orchid", + "name": "Orchid Systems Biology", + "registeredAt": "2026-05-04T16:10:00.000Z", + "submissionStartedAt": "2026-05-12T20:00:00.000Z", + "lastActiveAt": "2026-05-14T19:15:00.000Z" + }, + { + "id": "team-quartz", + "name": "Quartz ML Group", + "registeredAt": "2026-05-08T08:40:00.000Z", + "submissionStartedAt": null, + "lastActiveAt": "2026-05-15T10:00:00.000Z" + } + ], + "reviewers": [ + { + "id": "reviewer-chen", + "assignedAt": "2026-05-15T12:00:00.000Z" + }, + { + "id": "reviewer-mbaye", + "assignedAt": "2026-05-15T12:05:00.000Z" + } + ], + "amendments": [ + { + "id": "amd-deadline-shorter", + "requestedBy": "sponsor", + "requestedAt": "2026-05-16T11:00:00.000Z", + "effectiveAt": "2026-05-18T09:00:00.000Z", + "field": "submissionDueAt", + "before": "2026-05-28T23:59:00.000Z", + "after": "2026-05-22T23:59:00.000Z", + "justification": "Sponsor board wants winners before June budget close.", + "notifiedTeamIds": ["team-atlas"], + "acknowledgedTeamIds": [] + }, + { + "id": "amd-rubric-shift", + "requestedBy": "sponsor", + "requestedAt": "2026-05-16T11:07:00.000Z", + "effectiveAt": "2026-05-20T09:00:00.000Z", + "field": "rubric", + "before": { + "accuracy": 40, + "reproducibility": 25, + "biological-plausibility": 20, + "documentation": 15 + }, + "after": { + "accuracy": 30, + "reproducibility": 20, + "biological-plausibility": 40, + "documentation": 10 + }, + "justification": "Sponsor wants to emphasize biological plausibility.", + "notifiedTeamIds": ["team-atlas", "team-orchid", "team-quartz"], + "acknowledgedTeamIds": ["team-quartz"] + }, + { + "id": "amd-ip-policy", + "requestedBy": "sponsor", + "requestedAt": "2026-05-16T12:00:00.000Z", + "effectiveAt": "2026-05-17T09:00:00.000Z", + "field": "ipPolicy", + "before": "solver_retains_until_paid", + "after": "sponsor_option_license_pre_award", + "justification": "Sponsor legal team requested earlier internal review rights.", + "notifiedTeamIds": [], + "acknowledgedTeamIds": [] + }, + { + "id": "amd-prize-increase", + "requestedBy": "sponsor", + "requestedAt": "2026-05-16T12:20:00.000Z", + "effectiveAt": "2026-05-16T13:00:00.000Z", + "field": "prizePoolUsd", + "before": 42000, + "after": 48000, + "justification": "Additional honorable mention funding approved.", + "notifiedTeamIds": ["team-atlas", "team-orchid", "team-quartz"], + "acknowledgedTeamIds": ["team-atlas", "team-orchid", "team-quartz"] + } + ] +} diff --git a/scientific-bounty-amendment-control/docs/demo.gif b/scientific-bounty-amendment-control/docs/demo.gif new file mode 100644 index 0000000..16acefa Binary files /dev/null and b/scientific-bounty-amendment-control/docs/demo.gif differ diff --git a/scientific-bounty-amendment-control/docs/demo.mp4 b/scientific-bounty-amendment-control/docs/demo.mp4 new file mode 100644 index 0000000..e9c7d58 Binary files /dev/null and b/scientific-bounty-amendment-control/docs/demo.mp4 differ diff --git a/scientific-bounty-amendment-control/docs/demo.svg b/scientific-bounty-amendment-control/docs/demo.svg new file mode 100644 index 0000000..65fcacb --- /dev/null +++ b/scientific-bounty-amendment-control/docs/demo.svg @@ -0,0 +1,34 @@ + + Scientific bounty amendment control demo + Dashboard preview showing material challenge amendments, solver notice gaps, evaluation holds, and signed audit evidence. + + + SCIBASE Scientific Bounty Amendment Control + Single-cell biomarker discovery challenge + Sponsor changes pause evaluation until solvers are protected + Deadline, rubric, prize, and IP changes produce notice packets, hold decisions, and audit digests. + + + Amendments + 4 + + Critical findings + 2 + + Missing notices + 3 + + Evaluation state + HOLD + + + Material amendment risks + + Submission window shortened after solver work + + IP policy changed before payout acceptance + + Hold decisions + evaluation: hold + payoutReadiness: hold_until_acknowledged + diff --git a/scientific-bounty-amendment-control/docs/requirement-map.md b/scientific-bounty-amendment-control/docs/requirement-map.md new file mode 100644 index 0000000..a0d6ba1 --- /dev/null +++ b/scientific-bounty-amendment-control/docs/requirement-map.md @@ -0,0 +1,18 @@ +# Requirement Map + +This module maps to SCIBASE issue #18, Scientific Bounty System. + +| Issue requirement | Implementation | +| --- | --- | +| Challenge posting portal | Validates sponsor amendments against published challenge terms, including deliverables, rubrics, prize amounts, timelines, prequalification rules, NDA posture, and IP policy. | +| Timeline and milestone deadlines | Flags shortened submission windows, review-start changes, short notice windows, and missing solver acknowledgement before changes take effect. | +| Prize amount and payout schedule | Detects prize decreases or increases, blocks payout readiness when material changes are not acknowledged, and emits signed hold decisions. | +| Evaluation criteria and scoring rubric | Computes rubric criterion shifts and freezes evaluation when rubric changes after submissions or reviewer assignments. | +| Public vs. private / anonymous participation | Treats visibility, NDA, prequalification, and IP policy changes as participation-term amendments requiring solver notice and acknowledgement. | +| Submission engine | Uses registered teams, active submissions, and reviewer assignments to decide who is affected by a sponsor amendment. | +| Arbitration and reward distribution | Places evaluation, reviewer-assignment, and payout-readiness holds when challenge terms change in a way that could unfairly affect solvers. | +| Audit logs for reproducibility | Generates deterministic finding IDs, solver-notification IDs, audit event IDs, and a final evidence digest. | + +## Non-overlap + +This is a challenge amendment control layer. It does not implement a general challenge intake system, solver workspace, scoring engine, arbitration module, appeals ledger, escrow settlement module, anti-collusion detector, or reproducibility audit gate. It focuses on the sponsor-change interval after a bounty is already live and before evaluation or payout readiness proceeds. diff --git a/scientific-bounty-amendment-control/package.json b/scientific-bounty-amendment-control/package.json new file mode 100644 index 0000000..c57c980 --- /dev/null +++ b/scientific-bounty-amendment-control/package.json @@ -0,0 +1,12 @@ +{ + "name": "scientific-bounty-amendment-control", + "version": "1.0.0", + "type": "module", + "private": true, + "scripts": { + "check": "node --check src/challenge-amendment-control.js && node --check scripts/demo.js && node --check scripts/write-demo-gif.js && node --check test/challenge-amendment-control.test.js", + "test": "node --test test/challenge-amendment-control.test.js", + "demo": "node scripts/demo.js", + "demo:gif": "node scripts/write-demo-gif.js" + } +} diff --git a/scientific-bounty-amendment-control/scripts/demo.js b/scientific-bounty-amendment-control/scripts/demo.js new file mode 100644 index 0000000..491ce0e --- /dev/null +++ b/scientific-bounty-amendment-control/scripts/demo.js @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { evaluateChallengeAmendments } from "../src/challenge-amendment-control.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-amendments.json"), "utf8")); +const report = evaluateChallengeAmendments(sample); + +console.log(JSON.stringify({ + challenge: report.challengeId, + digest: report.evidenceDigest, + summary: report.summary, + criticalAmendments: report.findings + .filter((finding) => finding.severity === "critical") + .map((finding) => ({ amendmentId: finding.amendmentId, field: finding.details.field, direction: finding.details.direction })), + holdDecisions: report.holdDecisions +}, null, 2)); diff --git a/scientific-bounty-amendment-control/scripts/write-demo-gif.js b/scientific-bounty-amendment-control/scripts/write-demo-gif.js new file mode 100644 index 0000000..611c907 --- /dev/null +++ b/scientific-bounty-amendment-control/scripts/write-demo-gif.js @@ -0,0 +1,118 @@ +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const out = join(root, "docs", "demo.gif"); + +function u16(value) { + return String.fromCharCode(value & 255, (value >> 8) & 255); +} + +function color(r, g, b) { + return String.fromCharCode(r, g, b); +} + +function minCodeSize(colorCount) { + return Math.max(2, Math.ceil(Math.log2(colorCount))); +} + +function packCodes(codes, width) { + let bits = 0; + let bitCount = 0; + const bytes = []; + for (const code of codes) { + bits |= code << bitCount; + bitCount += width; + while (bitCount >= 8) { + bytes.push(bits & 255); + bits >>= 8; + bitCount -= 8; + } + } + if (bitCount > 0) bytes.push(bits & 255); + return bytes; +} + +function imageData(indices, paletteSize) { + const size = minCodeSize(paletteSize); + const clear = 1 << size; + const end = clear + 1; + const width = size + 1; + const codes = [clear]; + for (let index = 0; index < indices.length; index += 4) { + if (index > 0) codes.push(clear); + codes.push(...indices.slice(index, index + 4)); + } + codes.push(end); + const bytes = packCodes(codes, width); + const blocks = []; + for (let index = 0; index < bytes.length; index += 255) { + const chunk = bytes.slice(index, index + 255); + blocks.push(String.fromCharCode(chunk.length, ...chunk)); + } + return String.fromCharCode(size) + blocks.join("") + "\x00"; +} + +function frame(width, height, indices, delayCs, paletteSize) { + return [ + "\x21\xF9\x04\x04", + u16(delayCs), + "\x00\x00", + "\x2C", + u16(0), + u16(0), + u16(width), + u16(height), + "\x00", + imageData(indices, paletteSize) + ].join(""); +} + +function makeFrame(width, height, accentIndex) { + const pixels = new Array(width * height).fill(0); + const fill = (x0, y0, x1, y1, idx) => { + for (let y = y0; y < y1; y += 1) { + for (let x = x0; x < x1; x += 1) pixels[y * width + x] = idx; + } + }; + + fill(0, 0, width, Math.floor(height * 0.14), 1); + fill(Math.floor(width * 0.03), Math.floor(height * 0.24), Math.floor(width * 0.32), Math.floor(height * 0.43), 2); + fill(Math.floor(width * 0.35), Math.floor(height * 0.24), Math.floor(width * 0.65), Math.floor(height * 0.43), 2); + fill(Math.floor(width * 0.68), Math.floor(height * 0.24), Math.floor(width * 0.97), Math.floor(height * 0.43), 2); + fill(Math.floor(width * 0.06), Math.floor(height * 0.63), Math.floor(width * 0.31), Math.floor(height * 0.72), accentIndex); + fill(Math.floor(width * 0.38), Math.floor(height * 0.63), Math.floor(width * 0.63), Math.floor(height * 0.72), 4); + fill(Math.floor(width * 0.69), Math.floor(height * 0.63), Math.floor(width * 0.94), Math.floor(height * 0.72), 5); + fill(Math.floor(width * 0.08), Math.floor(height * 0.83), Math.floor(width * 0.92), Math.floor(height * 0.89), accentIndex); + return pixels; +} + +const width = 960; +const height = 540; +const palette = [ + color(246, 247, 251), + color(17, 24, 39), + color(255, 255, 255), + color(185, 28, 28), + color(37, 99, 235), + color(180, 83, 9), + color(22, 163, 74), + color(209, 213, 219) +].join(""); + +const gif = [ + "GIF89a", + u16(width), + u16(height), + "\xF2\x00\x00", + palette, + "\x21\xFF\x0BNETSCAPE2.0\x03\x01\x00\x00\x00", + frame(width, height, makeFrame(width, height, 3), 80, 8), + frame(width, height, makeFrame(width, height, 5), 80, 8), + frame(width, height, makeFrame(width, height, 6), 80, 8), + ";" +].join(""); + +writeFileSync(out, Buffer.from(gif, "binary")); +console.log(`wrote ${out}`); diff --git a/scientific-bounty-amendment-control/src/challenge-amendment-control.js b/scientific-bounty-amendment-control/src/challenge-amendment-control.js new file mode 100644 index 0000000..80adc4a --- /dev/null +++ b/scientific-bounty-amendment-control/src/challenge-amendment-control.js @@ -0,0 +1,268 @@ +import { createHash } from "node:crypto"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const MATERIAL_FIELDS = new Set([ + "submissionDueAt", + "reviewStartsAt", + "prizePoolUsd", + "ipPolicy", + "visibility", + "requiredDeliverables", + "rubric", + "ndaRequired", + "prequalificationRules" +]); + +function stableHash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +function asDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw new Error(`Invalid date: ${value}`); + return date; +} + +function daysBetween(start, end) { + return Math.ceil((asDate(end).getTime() - asDate(start).getTime()) / DAY_MS); +} + +function startedTeams(input) { + return (input.teams ?? []).filter((team) => team.submissionStartedAt); +} + +function activeTeams(input) { + return (input.teams ?? []).filter((team) => team.registeredAt); +} + +function reviewersAssigned(input) { + return (input.reviewers ?? []).some((reviewer) => reviewer.assignedAt); +} + +function valueChanged(amendment) { + return JSON.stringify(amendment.before) !== JSON.stringify(amendment.after); +} + +function rubricDelta(before = {}, after = {}) { + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + let largestShift = 0; + const shifts = []; + for (const key of keys) { + const from = before[key] ?? 0; + const to = after[key] ?? 0; + const delta = to - from; + if (delta !== 0) shifts.push({ criterion: key, before: from, after: to, delta }); + largestShift = Math.max(largestShift, Math.abs(delta)); + } + return { largestShift, shifts: shifts.sort((a, b) => b.delta - a.delta || a.criterion.localeCompare(b.criterion)) }; +} + +function missingNotifications(amendment, teams) { + const notified = new Set(amendment.notifiedTeamIds ?? []); + return teams.filter((team) => !notified.has(team.id)).map((team) => team.id).sort(); +} + +function missingAcknowledgements(amendment, teams) { + const acknowledged = new Set(amendment.acknowledgedTeamIds ?? []); + return teams.filter((team) => !acknowledged.has(team.id)).map((team) => team.id).sort(); +} + +function amendmentDirection(amendment) { + if (amendment.field === "submissionDueAt" || amendment.field === "reviewStartsAt") { + const shiftDays = daysBetween(amendment.before, amendment.after); + return shiftDays < 0 ? "shortens_window" : shiftDays > 0 ? "extends_window" : "unchanged"; + } + + if (amendment.field === "prizePoolUsd") { + const deltaUsd = Number(amendment.after) - Number(amendment.before); + return deltaUsd < 0 ? "decreases_prize" : deltaUsd > 0 ? "increases_prize" : "unchanged"; + } + + if (amendment.field === "ipPolicy" || amendment.field === "ndaRequired" || amendment.field === "visibility") { + return "changes_participation_terms"; + } + + if (amendment.field === "requiredDeliverables") return "changes_deliverables"; + if (amendment.field === "rubric") return "changes_rubric"; + if (amendment.field === "prequalificationRules") return "changes_eligibility"; + return "changes_terms"; +} + +function classifySeverity({ amendment, direction, startedCount, missingNotice, missingAck, effectiveNoticeDays, reviewerAssigned }) { + if (!valueChanged(amendment)) return "low"; + + const afterWorkStarted = startedCount > 0; + const missingAnyNotice = missingNotice.length > 0; + const missingAnyAck = missingAck.length > 0; + const shortNotice = effectiveNoticeDays < 3; + + if (direction === "changes_participation_terms" && afterWorkStarted) return "critical"; + if (direction === "shortens_window" && afterWorkStarted && (shortNotice || missingAnyNotice)) return "critical"; + if (direction === "decreases_prize" && afterWorkStarted) return "critical"; + if ((direction === "changes_rubric" || direction === "changes_deliverables") && afterWorkStarted && reviewerAssigned) return "high"; + if ((direction === "changes_rubric" || direction === "changes_deliverables") && (missingAnyNotice || missingAnyAck)) return "high"; + if (MATERIAL_FIELDS.has(amendment.field) && (missingAnyNotice || missingAnyAck)) return "medium"; + return "low"; +} + +function buildFinding(input, amendment) { + const started = startedTeams(input); + const active = activeTeams(input); + const affectedTeams = amendment.field === "prequalificationRules" ? active : started.length > 0 ? started : active; + const missingNotice = missingNotifications(amendment, affectedTeams); + const missingAck = missingAcknowledgements(amendment, affectedTeams); + const direction = amendmentDirection(amendment); + const effectiveNoticeDays = daysBetween(amendment.requestedAt, amendment.effectiveAt); + const reviewerAssigned = reviewersAssigned(input); + const severity = classifySeverity({ + amendment, + direction, + startedCount: started.length, + missingNotice, + missingAck, + effectiveNoticeDays, + reviewerAssigned + }); + + const details = { + field: amendment.field, + direction, + affectedTeamIds: affectedTeams.map((team) => team.id).sort(), + missingNoticeTeamIds: missingNotice, + missingAcknowledgementTeamIds: missingAck, + effectiveNoticeDays, + reviewersAssigned: reviewerAssigned + }; + + if (amendment.field === "rubric") details.rubricDelta = rubricDelta(amendment.before, amendment.after); + if (amendment.field === "prizePoolUsd") details.prizeDeltaUsd = Number(amendment.after) - Number(amendment.before); + if (amendment.field === "submissionDueAt" || amendment.field === "reviewStartsAt") { + details.scheduleDeltaDays = daysBetween(amendment.before, amendment.after); + } + + return { + id: stableHash({ amendmentId: amendment.id, details }), + amendmentId: amendment.id, + severity, + material: MATERIAL_FIELDS.has(amendment.field), + message: `${amendment.field} ${direction.replaceAll("_", " ")}`, + details + }; +} + +function severityRank(severity) { + return { critical: 0, high: 1, medium: 2, low: 3 }[severity] ?? 4; +} + +function buildNotifications(input, findings) { + const teamById = new Map((input.teams ?? []).map((team) => [team.id, team])); + + return findings.flatMap((finding) => { + const amendment = input.amendments.find((item) => item.id === finding.amendmentId); + const notified = new Set(amendment.notifiedTeamIds ?? []); + const acknowledged = new Set(amendment.acknowledgedTeamIds ?? []); + + return finding.details.affectedTeamIds.map((teamId) => ({ + notificationId: stableHash({ amendmentId: finding.amendmentId, teamId }), + amendmentId: finding.amendmentId, + teamId, + teamName: teamById.get(teamId)?.name ?? teamId, + required: finding.material, + sent: notified.has(teamId), + acknowledged: acknowledged.has(teamId), + priority: finding.severity === "critical" ? "urgent" : finding.severity === "high" ? "high" : "normal", + summary: `${amendment.field} update requires ${acknowledged.has(teamId) ? "no further acknowledgement" : "team acknowledgement"}` + })); + }).sort((a, b) => a.amendmentId.localeCompare(b.amendmentId) || a.teamId.localeCompare(b.teamId)); +} + +function holdDecisions(findings) { + const hasCritical = findings.some((finding) => finding.severity === "critical"); + const hasHigh = findings.some((finding) => finding.severity === "high"); + const missingNotice = findings.flatMap((finding) => finding.details.missingNoticeTeamIds); + const missingAck = findings.flatMap((finding) => finding.details.missingAcknowledgementTeamIds); + + return { + evaluation: hasCritical || missingNotice.length > 0 ? "hold" : hasHigh ? "review_before_start" : "release", + reviewerAssignments: hasCritical || hasHigh ? "freeze_new_assignments" : "release", + payoutReadiness: hasCritical || missingAck.length > 0 ? "hold_until_acknowledged" : "release", + reasons: [ + ...(hasCritical ? ["critical_material_amendment"] : []), + ...(hasHigh ? ["high_risk_material_amendment"] : []), + ...(missingNotice.length > 0 ? ["solver_notice_incomplete"] : []), + ...(missingAck.length > 0 ? ["solver_acknowledgement_incomplete"] : []) + ] + }; +} + +function auditEvents(input, findings, notifications, holds) { + const events = [ + ...findings.map((finding) => ({ + type: "amendment_risk_classified", + amendmentId: finding.amendmentId, + severity: finding.severity, + material: finding.material, + digest: stableHash(finding) + })), + ...notifications + .filter((notification) => notification.required && (!notification.sent || !notification.acknowledged)) + .map((notification) => ({ + type: "solver_notice_required", + amendmentId: notification.amendmentId, + teamId: notification.teamId, + sent: notification.sent, + acknowledged: notification.acknowledged, + digest: stableHash(notification) + })), + { + type: "hold_decision_recorded", + challengeId: input.challenge.id, + evaluation: holds.evaluation, + payoutReadiness: holds.payoutReadiness, + digest: stableHash(holds) + } + ]; + + return events.map((event) => ({ + ...event, + eventId: stableHash(event) + })); +} + +export function evaluateChallengeAmendments(input, options = {}) { + const generatedAt = options.generatedAt ?? input.generatedAt ?? new Date().toISOString(); + const findings = (input.amendments ?? []) + .filter((amendment) => valueChanged(amendment)) + .map((amendment) => buildFinding(input, amendment)) + .sort((a, b) => { + const bySeverity = severityRank(a.severity) - severityRank(b.severity); + if (bySeverity !== 0) return bySeverity; + return a.amendmentId.localeCompare(b.amendmentId); + }); + const notifications = buildNotifications(input, findings); + const holds = holdDecisions(findings); + const events = auditEvents(input, findings, notifications, holds); + + return { + challengeId: input.challenge.id, + challengeTitle: input.challenge.title, + generatedAt, + summary: { + amendmentsReviewed: input.amendments?.length ?? 0, + materialAmendments: findings.filter((finding) => finding.material).length, + criticalFindings: findings.filter((finding) => finding.severity === "critical").length, + highFindings: findings.filter((finding) => finding.severity === "high").length, + teamsAffected: new Set(findings.flatMap((finding) => finding.details.affectedTeamIds)).size, + missingNotifications: notifications.filter((notification) => notification.required && !notification.sent).length, + missingAcknowledgements: notifications.filter((notification) => notification.required && !notification.acknowledged).length, + evaluationDecision: holds.evaluation, + payoutDecision: holds.payoutReadiness + }, + findings, + solverNotifications: notifications, + holdDecisions: holds, + auditEvents: events, + evidenceDigest: stableHash({ generatedAt, findings, notifications, holds, events }) + }; +} diff --git a/scientific-bounty-amendment-control/test/challenge-amendment-control.test.js b/scientific-bounty-amendment-control/test/challenge-amendment-control.test.js new file mode 100644 index 0000000..0eec9a1 --- /dev/null +++ b/scientific-bounty-amendment-control/test/challenge-amendment-control.test.js @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { evaluateChallengeAmendments } from "../src/challenge-amendment-control.js"; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const sample = JSON.parse(readFileSync(join(root, "data", "sample-amendments.json"), "utf8")); + +describe("evaluateChallengeAmendments", () => { + it("detects material sponsor changes after solver work has started", () => { + const report = evaluateChallengeAmendments(sample); + const byAmendment = new Map(report.findings.map((finding) => [finding.amendmentId, finding])); + + assert.equal(report.summary.amendmentsReviewed, 4); + assert.equal(report.summary.materialAmendments, 4); + assert.equal(byAmendment.get("amd-deadline-shorter").severity, "critical"); + assert.equal(byAmendment.get("amd-rubric-shift").severity, "high"); + assert.equal(byAmendment.get("amd-ip-policy").severity, "critical"); + }); + + it("holds evaluation and payout readiness until teams receive notice and acknowledge", () => { + const report = evaluateChallengeAmendments(sample); + + assert.equal(report.summary.evaluationDecision, "hold"); + assert.equal(report.summary.payoutDecision, "hold_until_acknowledged"); + assert.equal(report.holdDecisions.reasons.includes("solver_notice_incomplete"), true); + assert.equal(report.holdDecisions.reasons.includes("solver_acknowledgement_incomplete"), true); + }); + + it("creates team notification packets for every affected material amendment", () => { + const report = evaluateChallengeAmendments(sample); + const ipPolicyNotices = report.solverNotifications.filter((notice) => notice.amendmentId === "amd-ip-policy"); + const prizeIncreaseNotices = report.solverNotifications.filter((notice) => notice.amendmentId === "amd-prize-increase"); + + assert.equal(ipPolicyNotices.length, 2); + assert.equal(ipPolicyNotices.every((notice) => notice.priority === "urgent"), true); + assert.equal(prizeIncreaseNotices.every((notice) => notice.sent && notice.acknowledged), true); + }); + + it("is deterministic for audit evidence", () => { + const first = evaluateChallengeAmendments(sample); + const second = evaluateChallengeAmendments(sample); + + assert.equal(first.evidenceDigest, second.evidenceDigest); + assert.deepEqual(first.auditEvents.map((event) => event.eventId), second.auditEvents.map((event) => event.eventId)); + }); +});