diff --git a/challenge-clarification-freeze-guard/README.md b/challenge-clarification-freeze-guard/README.md new file mode 100644 index 0000000..40db588 --- /dev/null +++ b/challenge-clarification-freeze-guard/README.md @@ -0,0 +1,18 @@ +# Challenge Clarification Freeze Guard + +This module adds a focused sponsor clarification and Q&A freeze guard for the Scientific Bounty System. + +It protects solvers from quiet scope drift by evaluating whether material sponsor questions were answered, broadcast to all eligible participants, linked to amendments when they changed rules, and captured in a final freeze digest before submissions are judged. + +## Run + +```sh +node challenge-clarification-freeze-guard/test.js +node challenge-clarification-freeze-guard/demo.js +``` + +The demo writes JSON and Markdown reviewer artifacts to `challenge-clarification-freeze-guard/reports/`. + +## Review Surface + +The implementation is dependency-free, uses synthetic data only, and does not call external APIs or read credentials. diff --git a/challenge-clarification-freeze-guard/acceptance-notes.md b/challenge-clarification-freeze-guard/acceptance-notes.md new file mode 100644 index 0000000..3381503 --- /dev/null +++ b/challenge-clarification-freeze-guard/acceptance-notes.md @@ -0,0 +1,26 @@ +# Acceptance Notes + +## What Changed + +Added `challenge-clarification-freeze-guard/`, a self-contained module for freezing sponsor Q&A before bounty submissions are judged. + +## How To Validate + +Run: + +```sh +node challenge-clarification-freeze-guard/test.js +node challenge-clarification-freeze-guard/demo.js +``` + +Optional syntax check: + +```sh +node --check challenge-clarification-freeze-guard/index.js +node --check challenge-clarification-freeze-guard/test.js +node --check challenge-clarification-freeze-guard/demo.js +``` + +## Why This Is Issue-Specific + +Issue #18 depends on trust between sponsors and solvers. This guard prevents hidden requirement changes by checking that material deliverable, rubric, payout, IP, NDA, and data-access clarifications are answered, broadcast, amendment-linked when rule-changing, and captured in a final digest before arbitration. diff --git a/challenge-clarification-freeze-guard/demo.js b/challenge-clarification-freeze-guard/demo.js new file mode 100644 index 0000000..205cf22 --- /dev/null +++ b/challenge-clarification-freeze-guard/demo.js @@ -0,0 +1,74 @@ +const fs = require("fs"); +const path = require("path"); +const { evaluateChallengeClarificationFreeze } = require("./index"); + +const outputDir = path.join(__dirname, "reports"); +fs.mkdirSync(outputDir, { recursive: true }); + +const challenge = { + challengeId: "challenge-climate-forecast-2026", + title: "Regional climate forecasting model challenge", + visibility: "private", + clarificationCutoff: "2026-06-10T17:00:00Z", + submissionDeadline: "2026-06-24T17:00:00Z", + teams: [{ id: "solver-lab" }, { id: "student-team" }, { id: "industry-team" }], + questions: [ + { + id: "q-rubric", + text: "Will reproducibility receive a separate score?", + tags: ["rubric"], + response: { + createdAt: "2026-06-07T16:00:00Z", + text: "Yes, reproducibility is a 20 point rubric category.", + changeTags: ["rubric"], + amendmentId: "amendment-02", + }, + }, + { + id: "q-data", + text: "Can teams use sponsor-provided holdout regions for tuning?", + tags: ["data-access"], + response: { + createdAt: "2026-06-11T09:00:00Z", + text: "No, holdout regions may only be used for final scoring.", + changeTags: [], + }, + }, + ], + broadcasts: [ + { questionId: "q-rubric", recipients: ["solver-lab", "student-team"], sentAt: "2026-06-07T16:05:00Z" }, + ], + freezeDigest: { + hash: "sha256:old-digest", + generatedAt: "2026-06-08T10:00:00Z", + }, +}; + +const report = evaluateChallengeClarificationFreeze(challenge); +const jsonPath = path.join(outputDir, "challenge-clarification-freeze-report.json"); +const markdownPath = path.join(outputDir, "challenge-clarification-freeze-report.md"); + +fs.writeFileSync(jsonPath, JSON.stringify(report, null, 2)); +fs.writeFileSync( + markdownPath, + [ + "# Challenge Clarification Freeze Guard Demo", + "", + `Decision: ${report.decision}`, + `Audit digest: ${report.auditDigest}`, + `Material questions: ${report.freezePacket.materialQuestionCount}`, + "", + "## Findings", + "", + ...report.findings.map((finding) => `- ${finding.severity}: ${finding.code} - ${finding.message}`), + "", + "## Notification Plan", + "", + ...report.freezePacket.notificationPlan.map((item) => `- ${item.questionId}: ${item.recipients.join(", ")} (${item.digest})`), + "", + ].join("\n"), +); + +console.log(`Wrote ${jsonPath}`); +console.log(`Wrote ${markdownPath}`); +console.log(`${report.decision}: ${report.findings.length} finding(s), ${report.auditDigest}`); diff --git a/challenge-clarification-freeze-guard/demo.mp4 b/challenge-clarification-freeze-guard/demo.mp4 new file mode 100644 index 0000000..a3dcb18 Binary files /dev/null and b/challenge-clarification-freeze-guard/demo.mp4 differ diff --git a/challenge-clarification-freeze-guard/demo.svg b/challenge-clarification-freeze-guard/demo.svg new file mode 100644 index 0000000..33218f0 --- /dev/null +++ b/challenge-clarification-freeze-guard/demo.svg @@ -0,0 +1,25 @@ + + + + Challenge Clarification Freeze Guard + Scientific bounty system slice for issue #18 + + Material Q&A + deliverable + rubric + payout + NDA/data access + + Freeze Rules + cutoff before deadline + rule-change amendment + all-solver broadcast + signed digest + + Arbitration Packet + findings + notifications + recipient digest + hold decision + Decision: hold submissions when clarifications change rules after cutoff. + diff --git a/challenge-clarification-freeze-guard/index.js b/challenge-clarification-freeze-guard/index.js new file mode 100644 index 0000000..84edc4f --- /dev/null +++ b/challenge-clarification-freeze-guard/index.js @@ -0,0 +1,214 @@ +const crypto = require("crypto"); + +function asArray(value) { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +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"); +} + +function parseTime(value) { + const time = Date.parse(value || ""); + return Number.isNaN(time) ? 0 : time; +} + +function normalize(value) { + return String(value || "").trim().toLowerCase(); +} + +function addFinding(findings, severity, code, target, message, remediation) { + findings.push({ severity, code, target, message, remediation }); +} + +function isMaterialQuestion(question) { + const tags = asArray(question.tags).map(normalize); + return tags.some((tag) => ["deliverable", "rubric", "timeline", "payout", "ip", "nda", "data-access"].includes(tag)); +} + +function responseChangesRules(response) { + const tags = asArray(response && response.changeTags).map(normalize); + return tags.some((tag) => ["deliverable", "rubric", "timeline", "payout", "ip-policy", "eligibility"].includes(tag)); +} + +function evaluateChallengeClarificationFreeze(packet) { + const challenge = packet || {}; + const questions = asArray(challenge.questions); + const broadcasts = asArray(challenge.broadcasts); + const teams = asArray(challenge.teams); + const cutoff = parseTime(challenge.clarificationCutoff); + const submissionDeadline = parseTime(challenge.submissionDeadline); + const findings = []; + const notificationPlan = []; + + if (!challenge.challengeId || !challenge.title) { + addFinding( + findings, + "blocker", + "CHALLENGE_CONTEXT_MISSING", + "challenge", + "Challenge id and title are required before clarification freeze.", + "Attach stable challenge identity so Q&A decisions can be audited against the correct bounty.", + ); + } + + if (!cutoff || !submissionDeadline) { + addFinding( + findings, + "blocker", + "FREEZE_TIMELINE_MISSING", + "timeline", + "Clarification cutoff and submission deadline are required.", + "Publish a cutoff before solvers spend time under unstable requirements.", + ); + } + + if (cutoff && submissionDeadline && cutoff >= submissionDeadline) { + addFinding( + findings, + "blocker", + "CUTOFF_AFTER_SUBMISSION_DEADLINE", + "clarificationCutoff", + "Clarification cutoff is not before the submission deadline.", + "Move the clarification freeze earlier than the final submission deadline.", + ); + } + + for (const question of questions) { + const response = question.response || null; + const material = isMaterialQuestion(question); + const target = `question:${question.id || "unknown"}`; + + if (material && !response) { + addFinding( + findings, + "blocker", + "MATERIAL_QUESTION_UNANSWERED", + target, + `Material solver question is unanswered: ${question.text || question.id}`, + "Answer material deliverable, rubric, timeline, payout, IP, NDA, or data-access questions before freezing the challenge.", + ); + } + + if (response && cutoff && parseTime(response.createdAt) > cutoff && responseChangesRules(response)) { + addFinding( + findings, + "blocker", + "POST_CUTOFF_RULE_CHANGE", + target, + "Sponsor response after the clarification cutoff changes challenge rules.", + "Convert the response into a formal amendment with solver re-consent and deadline extension.", + ); + } + + if (response && responseChangesRules(response) && !response.amendmentId) { + addFinding( + findings, + "warning", + "RULE_CHANGE_NOT_AMENDED", + target, + "A clarification changes rules but is not linked to an amendment id.", + "Link material rule changes to the amendment ledger so arbitration can distinguish clarification from scope expansion.", + ); + } + + const requiredAudience = challenge.visibility === "private" ? teams.map((team) => team.id) : ["public"]; + const matchingBroadcast = broadcasts.find((broadcast) => broadcast.questionId === question.id); + if (response && material && !matchingBroadcast) { + addFinding( + findings, + "blocker", + "MATERIAL_RESPONSE_NOT_BROADCAST", + target, + "Material clarification response was not broadcast to all eligible solvers.", + "Broadcast the response to all eligible teams or the public Q&A digest before accepting submissions.", + ); + } + + if (matchingBroadcast) { + const recipients = asArray(matchingBroadcast.recipients); + const missingRecipients = requiredAudience.filter((recipient) => !recipients.includes(recipient)); + if (missingRecipients.length > 0) { + addFinding( + findings, + "warning", + "BROADCAST_AUDIENCE_INCOMPLETE", + target, + `Clarification broadcast missed ${missingRecipients.length} eligible recipient(s).`, + "Send the clarification to every eligible solver team and include it in the freeze digest.", + ); + } + + notificationPlan.push({ + questionId: question.id, + recipients, + digest: digest({ questionId: question.id, response, recipients }), + }); + } + } + + const staleDigest = challenge.freezeDigest && challenge.freezeDigest.generatedAt && parseTime(challenge.freezeDigest.generatedAt) < Math.max(...questions.map((q) => parseTime(q.response && q.response.createdAt)), 0); + if (!challenge.freezeDigest || !challenge.freezeDigest.hash) { + addFinding( + findings, + "blocker", + "FREEZE_DIGEST_MISSING", + "freezeDigest", + "Challenge lacks a final Q&A freeze digest.", + "Generate a signed freeze digest containing every material question, response, broadcast audience, and amendment link.", + ); + } else if (staleDigest) { + addFinding( + findings, + "warning", + "FREEZE_DIGEST_STALE", + "freezeDigest", + "Freeze digest predates one or more clarification responses.", + "Regenerate the digest after the latest sponsor response.", + ); + } + + const blockers = findings.filter((finding) => finding.severity === "blocker"); + const warnings = findings.filter((finding) => finding.severity === "warning"); + const packetOut = { + challengeId: challenge.challengeId, + decision: blockers.length > 0 ? "hold-submissions" : warnings.length > 0 ? "freeze-with-warnings" : "ready-to-freeze", + counts: { + blocker: blockers.length, + warning: warnings.length, + info: findings.filter((finding) => finding.severity === "info").length, + }, + freezePacket: { + clarificationCutoff: challenge.clarificationCutoff, + submissionDeadline: challenge.submissionDeadline, + materialQuestionCount: questions.filter(isMaterialQuestion).length, + broadcastCount: broadcasts.length, + notificationPlan, + }, + findings, + }; + + return { + ...packetOut, + auditDigest: digest(packetOut), + }; +} + +module.exports = { + evaluateChallengeClarificationFreeze, + isMaterialQuestion, + responseChangesRules, + stableStringify, +}; diff --git a/challenge-clarification-freeze-guard/reports/challenge-clarification-freeze-report.json b/challenge-clarification-freeze-guard/reports/challenge-clarification-freeze-report.json new file mode 100644 index 0000000..356f8c4 --- /dev/null +++ b/challenge-clarification-freeze-guard/reports/challenge-clarification-freeze-report.json @@ -0,0 +1,49 @@ +{ + "challengeId": "challenge-climate-forecast-2026", + "decision": "hold-submissions", + "counts": { + "blocker": 1, + "warning": 2, + "info": 0 + }, + "freezePacket": { + "clarificationCutoff": "2026-06-10T17:00:00Z", + "submissionDeadline": "2026-06-24T17:00:00Z", + "materialQuestionCount": 2, + "broadcastCount": 1, + "notificationPlan": [ + { + "questionId": "q-rubric", + "recipients": [ + "solver-lab", + "student-team" + ], + "digest": "9757627b6256208ff855c81454d61a8c153e17aa4775485886a35532f9f4d424" + } + ] + }, + "findings": [ + { + "severity": "warning", + "code": "BROADCAST_AUDIENCE_INCOMPLETE", + "target": "question:q-rubric", + "message": "Clarification broadcast missed 1 eligible recipient(s).", + "remediation": "Send the clarification to every eligible solver team and include it in the freeze digest." + }, + { + "severity": "blocker", + "code": "MATERIAL_RESPONSE_NOT_BROADCAST", + "target": "question:q-data", + "message": "Material clarification response was not broadcast to all eligible solvers.", + "remediation": "Broadcast the response to all eligible teams or the public Q&A digest before accepting submissions." + }, + { + "severity": "warning", + "code": "FREEZE_DIGEST_STALE", + "target": "freezeDigest", + "message": "Freeze digest predates one or more clarification responses.", + "remediation": "Regenerate the digest after the latest sponsor response." + } + ], + "auditDigest": "3ebf52fd8e857429d195e15234d8f151cc5c773d688585f8586d25d2aeb46020" +} \ No newline at end of file diff --git a/challenge-clarification-freeze-guard/reports/challenge-clarification-freeze-report.md b/challenge-clarification-freeze-guard/reports/challenge-clarification-freeze-report.md new file mode 100644 index 0000000..c394cde --- /dev/null +++ b/challenge-clarification-freeze-guard/reports/challenge-clarification-freeze-report.md @@ -0,0 +1,15 @@ +# Challenge Clarification Freeze Guard Demo + +Decision: hold-submissions +Audit digest: 3ebf52fd8e857429d195e15234d8f151cc5c773d688585f8586d25d2aeb46020 +Material questions: 2 + +## Findings + +- warning: BROADCAST_AUDIENCE_INCOMPLETE - Clarification broadcast missed 1 eligible recipient(s). +- blocker: MATERIAL_RESPONSE_NOT_BROADCAST - Material clarification response was not broadcast to all eligible solvers. +- warning: FREEZE_DIGEST_STALE - Freeze digest predates one or more clarification responses. + +## Notification Plan + +- q-rubric: solver-lab, student-team (9757627b6256208ff855c81454d61a8c153e17aa4775485886a35532f9f4d424) diff --git a/challenge-clarification-freeze-guard/requirements-map.md b/challenge-clarification-freeze-guard/requirements-map.md new file mode 100644 index 0000000..0d569a1 --- /dev/null +++ b/challenge-clarification-freeze-guard/requirements-map.md @@ -0,0 +1,15 @@ +# Requirements Map + +Issue #18 asks for a Scientific Bounty System with challenge posting, submission workspaces, multi-phase challenges, arbitration, and payout trust. + +| Issue requirement | Implementation coverage | +| --- | --- | +| Challenge posting portal | Checks sponsor clarification readiness before solvers commit work. | +| Evaluation criteria and scoring rubric | Treats rubric questions and rubric-changing responses as material. | +| Timeline and milestone deadlines | Requires clarification cutoff before the submission deadline. | +| Public vs private challenges | Verifies public digest or private eligible-team broadcasts. | +| NDA support for sensitive topics | Treats NDA and data-access questions as material. | +| Feedback loop between submitters and sponsors | Ensures answers are broadcast and frozen in an audit digest. | +| Arbitration and reward trust | Blocks submissions when post-cutoff answers change rules without amendment/re-consent. | + +This slice is distinct from existing submissions because it focuses on sponsor Q&A freeze governance, not challenge rubric authoring, milestone progress, evidence tamper-diffing, IP redaction, reviewer consensus, payout eligibility, prequalification, amendment consent, or deliverable acceptance windows. diff --git a/challenge-clarification-freeze-guard/test.js b/challenge-clarification-freeze-guard/test.js new file mode 100644 index 0000000..3b87866 --- /dev/null +++ b/challenge-clarification-freeze-guard/test.js @@ -0,0 +1,130 @@ +const assert = require("assert"); +const { evaluateChallengeClarificationFreeze, isMaterialQuestion } = require("./index"); + +function readyPacket(overrides = {}) { + return { + challengeId: "challenge-biomarker-2026", + title: "Identify biomarker candidates from single-cell RNA-seq", + visibility: "private", + clarificationCutoff: "2026-06-10T17:00:00Z", + submissionDeadline: "2026-06-24T17:00:00Z", + teams: [{ id: "team-a" }, { id: "team-b" }], + questions: [ + { + id: "q1", + text: "Does the final deliverable require a trained model or a reproducible notebook?", + tags: ["deliverable"], + response: { + createdAt: "2026-06-04T14:00:00Z", + text: "A reproducible notebook plus candidate ranking table is required.", + changeTags: [], + }, + }, + { + id: "q2", + text: "Can teams cite sponsor-provided raw counts?", + tags: ["data-access"], + response: { + createdAt: "2026-06-05T11:00:00Z", + text: "Yes, but raw counts must stay inside the private workspace.", + changeTags: [], + }, + }, + ], + broadcasts: [ + { questionId: "q1", recipients: ["team-a", "team-b"], sentAt: "2026-06-04T14:05:00Z" }, + { questionId: "q2", recipients: ["team-a", "team-b"], sentAt: "2026-06-05T11:05:00Z" }, + ], + freezeDigest: { + hash: "sha256:freeze-digest", + generatedAt: "2026-06-10T17:01:00Z", + }, + ...overrides, + }; +} + +function testReadyPacket() { + const result = evaluateChallengeClarificationFreeze(readyPacket()); + assert.equal(result.decision, "ready-to-freeze"); + assert.equal(result.counts.blocker, 0); + assert.equal(result.freezePacket.materialQuestionCount, 2); +} + +function testMaterialQuestionNeedsAnswer() { + const result = evaluateChallengeClarificationFreeze( + readyPacket({ + questions: [ + { + id: "q-material", + text: "Will the scoring rubric weight reproducibility?", + tags: ["rubric"], + }, + ], + broadcasts: [], + }), + ); + + assert.equal(result.decision, "hold-submissions"); + assert.ok(result.findings.some((finding) => finding.code === "MATERIAL_QUESTION_UNANSWERED")); +} + +function testPostCutoffRuleChangeRequiresAmendment() { + const result = evaluateChallengeClarificationFreeze( + readyPacket({ + questions: [ + { + id: "q-late", + text: "Can the sponsor change payout milestones?", + tags: ["payout"], + response: { + createdAt: "2026-06-11T12:00:00Z", + text: "The sponsor now requires a third milestone.", + changeTags: ["payout"], + }, + }, + ], + broadcasts: [{ questionId: "q-late", recipients: ["team-a", "team-b"], sentAt: "2026-06-11T12:10:00Z" }], + }), + ); + + assert.equal(result.decision, "hold-submissions"); + assert.ok(result.findings.some((finding) => finding.code === "POST_CUTOFF_RULE_CHANGE")); +} + +function testMissingBroadcastBlocks() { + const result = evaluateChallengeClarificationFreeze( + readyPacket({ + questions: [ + { + id: "q-private", + text: "Does the NDA allow external contractors?", + tags: ["nda"], + response: { + createdAt: "2026-06-02T12:00:00Z", + text: "No external contractors are allowed.", + changeTags: [], + }, + }, + ], + broadcasts: [], + }), + ); + + assert.equal(result.decision, "hold-submissions"); + assert.ok(result.findings.some((finding) => finding.code === "MATERIAL_RESPONSE_NOT_BROADCAST")); +} + +function testDeterministicDigest() { + const first = evaluateChallengeClarificationFreeze(readyPacket()); + const second = evaluateChallengeClarificationFreeze(readyPacket()); + assert.equal(first.auditDigest, second.auditDigest); +} + +assert.equal(isMaterialQuestion({ tags: ["deliverable"] }), true); +testReadyPacket(); +testMaterialQuestionNeedsAnswer(); +testPostCutoffRuleChangeRequiresAmendment(); +testMissingBroadcastBlocks(); +testDeterministicDigest(); + +console.log("challenge-clarification-freeze-guard tests passed");