Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions challenge-clarification-freeze-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions challenge-clarification-freeze-guard/acceptance-notes.md
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions challenge-clarification-freeze-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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}`);
Binary file added challenge-clarification-freeze-guard/demo.mp4
Binary file not shown.
25 changes: 25 additions & 0 deletions challenge-clarification-freeze-guard/demo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
214 changes: 214 additions & 0 deletions challenge-clarification-freeze-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading