diff --git a/challenge-communication-parity-guard/README.md b/challenge-communication-parity-guard/README.md
new file mode 100644
index 00000000..601f4db0
--- /dev/null
+++ b/challenge-communication-parity-guard/README.md
@@ -0,0 +1,17 @@
+# Challenge Communication Parity Guard
+
+Self-contained SCIBASE Scientific Bounty System slice for issue #18. The guard checks whether material sponsor communications reach every eligible solver team with equal rule context, usable delivery channels, accessible attachments, and fair acknowledgement timing before submission, scoring, or payout steps continue.
+
+## Why this slice is distinct
+
+Existing #18 submissions cover intake, rubric readiness, clarification freeze, deadline extension fairness, arbitration, appeals, escrow, payout routing, award transparency, debrief feedback, duplicate solver detection, and sponsor compliance. This module focuses only on communication parity across the challenge lifecycle: hidden hints, missed broadcasts, stale rule digests, delivery-channel gaps, attachment accessibility, and acknowledgement windows.
+
+## Run
+
+```bash
+npm test
+npm run demo
+npm run demo:video
+```
+
+Demo artifacts are written to `reports/`, including JSON, Markdown, SVG, GIF, and MP4 files.
diff --git a/challenge-communication-parity-guard/demo.js b/challenge-communication-parity-guard/demo.js
new file mode 100644
index 00000000..e9753da3
--- /dev/null
+++ b/challenge-communication-parity-guard/demo.js
@@ -0,0 +1,65 @@
+const fs = require("fs");
+const path = require("path");
+
+const { assessCommunicationParity } = require("./index");
+const { completeChallenge, riskyChallenge } = require("./sample-data");
+
+const reportsDir = path.join(__dirname, "reports");
+fs.mkdirSync(reportsDir, { recursive: true });
+
+const scenarios = [
+ ["complete-challenge", completeChallenge],
+ ["risky-challenge", riskyChallenge],
+];
+
+function markdownReport(name, report) {
+ const findings = report.findings.length
+ ? report.findings
+ .map(
+ (item) =>
+ `- ${item.severity.toUpperCase()} ${item.code}: ${item.message} Affected: ${item.affectedTeams.join(", ")}`
+ )
+ .join("\n")
+ : "- No parity findings.";
+
+ return `# ${report.title}
+
+Scenario: ${name}
+
+Decision: ${report.decision.toUpperCase()}
+
+Reviewed ${report.summary.communicationsReviewed} communications across ${report.summary.eligibleTeams} eligible teams.
+
+## Findings
+
+${findings}
+
+## Release Criteria
+
+${report.releaseCriteria.map((item) => `- ${item}`).join("\n")}
+`;
+}
+
+function svgReport(report) {
+ const color = report.decision === "hold" ? "#dc2626" : report.decision === "revise" ? "#d97706" : "#16a34a";
+ return ``;
+}
+
+for (const [name, challenge] of scenarios) {
+ const report = assessCommunicationParity(challenge);
+ fs.writeFileSync(path.join(reportsDir, `${name}.json`), JSON.stringify(report, null, 2));
+ fs.writeFileSync(path.join(reportsDir, `${name}.md`), markdownReport(name, report));
+ fs.writeFileSync(path.join(reportsDir, `${name}.svg`), svgReport(report));
+ console.log(`${name}: ${report.decision} (${report.summary.findings} findings)`);
+}
diff --git a/challenge-communication-parity-guard/demo_video.py b/challenge-communication-parity-guard/demo_video.py
new file mode 100644
index 00000000..2c5bc856
--- /dev/null
+++ b/challenge-communication-parity-guard/demo_video.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import imageio.v3 as iio
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+
+
+ROOT = Path(__file__).resolve().parent
+REPORTS = ROOT / "reports"
+REPORTS.mkdir(exist_ok=True)
+
+
+def font(size):
+ for name in ("arial.ttf", "segoeui.ttf"):
+ try:
+ return ImageFont.truetype(name, size)
+ except OSError:
+ pass
+ return ImageFont.load_default()
+
+
+slides = [
+ ("Communication Parity Guard", "Scientific Bounty System #18"),
+ ("Checks", "hidden hints, missed broadcasts, stale rule digests"),
+ ("Checks", "channel coverage, accessible attachments, fair ack windows"),
+ ("Decision", "hold unsafe phase transitions until every team has parity"),
+]
+
+frames = []
+for index, (title, subtitle) in enumerate(slides, start=1):
+ image = Image.new("RGB", (960, 544), "#111827")
+ draw = ImageDraw.Draw(image)
+ draw.rectangle((42, 48, 918, 496), outline="#22c55e", width=3)
+ draw.text((78, 118), title, fill="#f9fafb", font=font(42))
+ draw.text((78, 202), subtitle, fill="#d1fae5", font=font(26))
+ draw.rectangle((78, 326, 798, 382), fill="#0f766e")
+ draw.text((102, 342), "material sponsor communications must reach every eligible team", fill="#ecfeff", font=font(21))
+ draw.text((78, 438), f"Slide {index}/4 - synthetic reviewer artifact", fill="#9ca3af", font=font(20))
+ frames.extend([image] * 14)
+
+gif_path = REPORTS / "demo.gif"
+mp4_path = REPORTS / "demo.mp4"
+frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
+iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
+print(f"wrote {gif_path}")
+print(f"wrote {mp4_path}")
diff --git a/challenge-communication-parity-guard/index.js b/challenge-communication-parity-guard/index.js
new file mode 100644
index 00000000..0d5903a5
--- /dev/null
+++ b/challenge-communication-parity-guard/index.js
@@ -0,0 +1,249 @@
+const HIGH = "high";
+const MEDIUM = "medium";
+const LOW = "low";
+
+function asArray(value, field) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(`${field} must be an array`);
+ }
+ return value;
+}
+
+function asNonEmptyString(value, field) {
+ if (typeof value !== "string" || value.trim() === "") {
+ throw new TypeError(`${field} must be a non-empty string`);
+ }
+ return value.trim();
+}
+
+function asDate(value, field) {
+ const date = new Date(value);
+ if (Number.isNaN(date.valueOf())) {
+ throw new TypeError(`${field} must be a valid date`);
+ }
+ return date;
+}
+
+function unique(values) {
+ return [...new Set(values)];
+}
+
+function normalizeTeam(team) {
+ return {
+ id: asNonEmptyString(team.id, "team.id"),
+ status: team.status || "eligible",
+ timezone: team.timezone || "UTC",
+ channels: unique(asArray(team.channels || [], "team.channels").map(String)),
+ accessibilityNeeds: unique(
+ asArray(team.accessibilityNeeds || [], "team.accessibilityNeeds").map(String)
+ ),
+ };
+}
+
+function normalizeAttachment(attachment) {
+ return {
+ id: asNonEmptyString(attachment.id, "attachment.id"),
+ required: Boolean(attachment.required),
+ accessibleFormats: unique(
+ asArray(attachment.accessibleFormats || [], "attachment.accessibleFormats").map(String)
+ ),
+ digest: attachment.digest ? String(attachment.digest) : "",
+ };
+}
+
+function normalizeCommunication(event) {
+ return {
+ id: asNonEmptyString(event.id, "communication.id"),
+ phase: asNonEmptyString(event.phase, "communication.phase"),
+ material: Boolean(event.material),
+ topic: asNonEmptyString(event.topic, "communication.topic"),
+ sentAt: asDate(event.sentAt, "communication.sentAt").toISOString(),
+ audience: unique(asArray(event.audience || [], "communication.audience").map(String)),
+ channels: unique(asArray(event.channels || [], "communication.channels").map(String)),
+ ruleDigest: event.ruleDigest ? String(event.ruleDigest) : "",
+ ackDeadlineHours: Number(event.ackDeadlineHours || 0),
+ acknowledgements: event.acknowledgements || {},
+ attachments: asArray(event.attachments || [], "communication.attachments").map(normalizeAttachment),
+ };
+}
+
+function normalizeChallenge(challenge) {
+ return {
+ challengeId: asNonEmptyString(challenge.challengeId, "challengeId"),
+ title: asNonEmptyString(challenge.title, "title"),
+ currentRuleDigest: asNonEmptyString(challenge.currentRuleDigest, "currentRuleDigest"),
+ clockStartsAt: challenge.clockStartsAt
+ ? asDate(challenge.clockStartsAt, "clockStartsAt").toISOString()
+ : null,
+ teams: asArray(challenge.teams || [], "teams").map(normalizeTeam),
+ communications: asArray(challenge.communications || [], "communications").map(
+ normalizeCommunication
+ ),
+ };
+}
+
+function finding(code, severity, event, message, teams, remediation) {
+ return {
+ code,
+ severity,
+ communicationId: event.id,
+ phase: event.phase,
+ topic: event.topic,
+ affectedTeams: teams,
+ message,
+ remediation,
+ };
+}
+
+function eligibleTeams(challenge) {
+ return challenge.teams.filter((team) => team.status === "eligible");
+}
+
+function assessCommunicationParity(rawChallenge) {
+ const challenge = normalizeChallenge(rawChallenge);
+ const eligible = eligibleTeams(challenge);
+ const eligibleIds = eligible.map((team) => team.id);
+ const teamById = new Map(eligible.map((team) => [team.id, team]));
+ const findings = [];
+
+ for (const event of challenge.communications) {
+ const missingAudience = eligibleIds.filter((teamId) => !event.audience.includes(teamId));
+ if (event.material && missingAudience.length > 0) {
+ findings.push(
+ finding(
+ "MATERIAL_AUDIENCE_GAP",
+ HIGH,
+ event,
+ "Material sponsor communication was not broadcast to every eligible solver team.",
+ missingAudience,
+ "Rebroadcast the exact message to all eligible teams and pause downstream decisions until receipt is logged."
+ )
+ );
+ }
+
+ const privateMaterialHint = event.material && event.audience.length > 0 && event.audience.length < eligibleIds.length;
+ if (privateMaterialHint) {
+ findings.push(
+ finding(
+ "PRIVATE_MATERIAL_HINT",
+ HIGH,
+ event,
+ "A material hint or rule-affecting message reached only a subset of teams.",
+ event.audience,
+ "Publish an immutable public clarification digest and reset any affected timing windows."
+ )
+ );
+ }
+
+ if (event.material && event.ruleDigest !== challenge.currentRuleDigest) {
+ findings.push(
+ finding(
+ "STALE_RULE_DIGEST",
+ MEDIUM,
+ event,
+ "Material communication references an outdated rule digest.",
+ event.audience,
+ "Attach the current rules digest before submissions, scoring, or payout routing continue."
+ )
+ );
+ }
+
+ const teamsWithoutChannel = event.audience.filter((teamId) => {
+ const team = teamById.get(teamId);
+ return team && !event.channels.some((channel) => team.channels.includes(channel));
+ });
+ if (teamsWithoutChannel.length > 0) {
+ findings.push(
+ finding(
+ "CHANNEL_COVERAGE_GAP",
+ MEDIUM,
+ event,
+ "At least one recipient team lacks a verified delivery channel for this communication.",
+ teamsWithoutChannel,
+ "Send through a channel registered for every recipient or collect a verified alternate channel."
+ )
+ );
+ }
+
+ for (const attachment of event.attachments.filter((item) => item.required)) {
+ const inaccessibleTeams = event.audience.filter((teamId) => {
+ const team = teamById.get(teamId);
+ if (!team || team.accessibilityNeeds.length === 0) {
+ return false;
+ }
+ return !team.accessibilityNeeds.every((need) => attachment.accessibleFormats.includes(need));
+ });
+ if (inaccessibleTeams.length > 0) {
+ findings.push(
+ finding(
+ "ACCESSIBLE_ATTACHMENT_GAP",
+ MEDIUM,
+ event,
+ `Required attachment ${attachment.id} does not cover recipient accessibility needs.`,
+ inaccessibleTeams,
+ "Add accessible equivalents and resend the attachment before the acknowledgement clock runs."
+ )
+ );
+ }
+ }
+
+ if (event.material && event.ackDeadlineHours > 0 && event.ackDeadlineHours < 24) {
+ findings.push(
+ finding(
+ "UNFAIR_ACK_WINDOW",
+ LOW,
+ event,
+ "Acknowledgement window is too short for timezone-safe team parity.",
+ event.audience,
+ "Use at least a 24 hour window for material communications unless every team explicitly waives it."
+ )
+ );
+ }
+
+ const missingAcknowledgements = event.material
+ ? event.audience.filter((teamId) => !event.acknowledgements[teamId])
+ : [];
+ if (missingAcknowledgements.length > 0) {
+ findings.push(
+ finding(
+ "MISSING_ACKNOWLEDGEMENT",
+ MEDIUM,
+ event,
+ "Material communication lacks acknowledgement from one or more recipient teams.",
+ missingAcknowledgements,
+ "Hold phase transitions until acknowledgement or documented delivery fallback is present."
+ )
+ );
+ }
+ }
+
+ const highCount = findings.filter((item) => item.severity === HIGH).length;
+ const mediumCount = findings.filter((item) => item.severity === MEDIUM).length;
+ const decision = highCount > 0 ? "hold" : mediumCount > 0 ? "revise" : "release";
+
+ return {
+ challengeId: challenge.challengeId,
+ title: challenge.title,
+ decision,
+ summary: {
+ eligibleTeams: eligibleIds.length,
+ communicationsReviewed: challenge.communications.length,
+ findings: findings.length,
+ high: highCount,
+ medium: mediumCount,
+ low: findings.filter((item) => item.severity === LOW).length,
+ },
+ findings,
+ releaseCriteria: [
+ "Every material sponsor communication reaches all eligible solver teams.",
+ "Rule-affecting messages reference the current immutable rule digest.",
+ "Delivery channels and required attachments are accessible to each recipient.",
+ "Material acknowledgements use fair windows and are logged before phase transitions.",
+ ],
+ };
+}
+
+module.exports = {
+ assessCommunicationParity,
+ normalizeChallenge,
+};
diff --git a/challenge-communication-parity-guard/package.json b/challenge-communication-parity-guard/package.json
new file mode 100644
index 00000000..1810fef5
--- /dev/null
+++ b/challenge-communication-parity-guard/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "challenge-communication-parity-guard",
+ "version": "1.0.0",
+ "description": "Synthetic communication parity guard for SCIBASE scientific bounty challenges",
+ "main": "index.js",
+ "type": "commonjs",
+ "scripts": {
+ "test": "node test.js",
+ "demo": "node demo.js",
+ "demo:video": "python demo_video.py"
+ },
+ "license": "MIT"
+}
diff --git a/challenge-communication-parity-guard/reports/complete-challenge.json b/challenge-communication-parity-guard/reports/complete-challenge.json
new file mode 100644
index 00000000..4fc35ec8
--- /dev/null
+++ b/challenge-communication-parity-guard/reports/complete-challenge.json
@@ -0,0 +1,20 @@
+{
+ "challengeId": "sb-comm-001",
+ "title": "Protein binder design bounty",
+ "decision": "release",
+ "summary": {
+ "eligibleTeams": 3,
+ "communicationsReviewed": 2,
+ "findings": 0,
+ "high": 0,
+ "medium": 0,
+ "low": 0
+ },
+ "findings": [],
+ "releaseCriteria": [
+ "Every material sponsor communication reaches all eligible solver teams.",
+ "Rule-affecting messages reference the current immutable rule digest.",
+ "Delivery channels and required attachments are accessible to each recipient.",
+ "Material acknowledgements use fair windows and are logged before phase transitions."
+ ]
+}
\ No newline at end of file
diff --git a/challenge-communication-parity-guard/reports/complete-challenge.md b/challenge-communication-parity-guard/reports/complete-challenge.md
new file mode 100644
index 00000000..62a5b353
--- /dev/null
+++ b/challenge-communication-parity-guard/reports/complete-challenge.md
@@ -0,0 +1,18 @@
+# Protein binder design bounty
+
+Scenario: complete-challenge
+
+Decision: RELEASE
+
+Reviewed 2 communications across 3 eligible teams.
+
+## Findings
+
+- No parity findings.
+
+## Release Criteria
+
+- Every material sponsor communication reaches all eligible solver teams.
+- Rule-affecting messages reference the current immutable rule digest.
+- Delivery channels and required attachments are accessible to each recipient.
+- Material acknowledgements use fair windows and are logged before phase transitions.
diff --git a/challenge-communication-parity-guard/reports/complete-challenge.svg b/challenge-communication-parity-guard/reports/complete-challenge.svg
new file mode 100644
index 00000000..619eab84
--- /dev/null
+++ b/challenge-communication-parity-guard/reports/complete-challenge.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/challenge-communication-parity-guard/reports/demo.gif b/challenge-communication-parity-guard/reports/demo.gif
new file mode 100644
index 00000000..f28bba8f
Binary files /dev/null and b/challenge-communication-parity-guard/reports/demo.gif differ
diff --git a/challenge-communication-parity-guard/reports/demo.mp4 b/challenge-communication-parity-guard/reports/demo.mp4
new file mode 100644
index 00000000..a23d0d7d
Binary files /dev/null and b/challenge-communication-parity-guard/reports/demo.mp4 differ
diff --git a/challenge-communication-parity-guard/reports/risky-challenge.json b/challenge-communication-parity-guard/reports/risky-challenge.json
new file mode 100644
index 00000000..5539045c
--- /dev/null
+++ b/challenge-communication-parity-guard/reports/risky-challenge.json
@@ -0,0 +1,132 @@
+{
+ "challengeId": "sb-comm-002",
+ "title": "Protein binder design bounty",
+ "decision": "hold",
+ "summary": {
+ "eligibleTeams": 3,
+ "communicationsReviewed": 2,
+ "findings": 9,
+ "high": 4,
+ "medium": 3,
+ "low": 2
+ },
+ "findings": [
+ {
+ "code": "MATERIAL_AUDIENCE_GAP",
+ "severity": "high",
+ "communicationId": "comm-101",
+ "phase": "submission",
+ "topic": "sponsor hint about hidden holdout metric",
+ "affectedTeams": [
+ "team-beta",
+ "team-gamma"
+ ],
+ "message": "Material sponsor communication was not broadcast to every eligible solver team.",
+ "remediation": "Rebroadcast the exact message to all eligible teams and pause downstream decisions until receipt is logged."
+ },
+ {
+ "code": "PRIVATE_MATERIAL_HINT",
+ "severity": "high",
+ "communicationId": "comm-101",
+ "phase": "submission",
+ "topic": "sponsor hint about hidden holdout metric",
+ "affectedTeams": [
+ "team-alpha"
+ ],
+ "message": "A material hint or rule-affecting message reached only a subset of teams.",
+ "remediation": "Publish an immutable public clarification digest and reset any affected timing windows."
+ },
+ {
+ "code": "STALE_RULE_DIGEST",
+ "severity": "medium",
+ "communicationId": "comm-101",
+ "phase": "submission",
+ "topic": "sponsor hint about hidden holdout metric",
+ "affectedTeams": [
+ "team-alpha"
+ ],
+ "message": "Material communication references an outdated rule digest.",
+ "remediation": "Attach the current rules digest before submissions, scoring, or payout routing continue."
+ },
+ {
+ "code": "ACCESSIBLE_ATTACHMENT_GAP",
+ "severity": "medium",
+ "communicationId": "comm-101",
+ "phase": "submission",
+ "topic": "sponsor hint about hidden holdout metric",
+ "affectedTeams": [
+ "team-alpha"
+ ],
+ "message": "Required attachment holdout-hint does not cover recipient accessibility needs.",
+ "remediation": "Add accessible equivalents and resend the attachment before the acknowledgement clock runs."
+ },
+ {
+ "code": "UNFAIR_ACK_WINDOW",
+ "severity": "low",
+ "communicationId": "comm-101",
+ "phase": "submission",
+ "topic": "sponsor hint about hidden holdout metric",
+ "affectedTeams": [
+ "team-alpha"
+ ],
+ "message": "Acknowledgement window is too short for timezone-safe team parity.",
+ "remediation": "Use at least a 24 hour window for material communications unless every team explicitly waives it."
+ },
+ {
+ "code": "MATERIAL_AUDIENCE_GAP",
+ "severity": "high",
+ "communicationId": "comm-102",
+ "phase": "payout",
+ "topic": "award settlement instructions",
+ "affectedTeams": [
+ "team-gamma"
+ ],
+ "message": "Material sponsor communication was not broadcast to every eligible solver team.",
+ "remediation": "Rebroadcast the exact message to all eligible teams and pause downstream decisions until receipt is logged."
+ },
+ {
+ "code": "PRIVATE_MATERIAL_HINT",
+ "severity": "high",
+ "communicationId": "comm-102",
+ "phase": "payout",
+ "topic": "award settlement instructions",
+ "affectedTeams": [
+ "team-alpha",
+ "team-beta"
+ ],
+ "message": "A material hint or rule-affecting message reached only a subset of teams.",
+ "remediation": "Publish an immutable public clarification digest and reset any affected timing windows."
+ },
+ {
+ "code": "UNFAIR_ACK_WINDOW",
+ "severity": "low",
+ "communicationId": "comm-102",
+ "phase": "payout",
+ "topic": "award settlement instructions",
+ "affectedTeams": [
+ "team-alpha",
+ "team-beta"
+ ],
+ "message": "Acknowledgement window is too short for timezone-safe team parity.",
+ "remediation": "Use at least a 24 hour window for material communications unless every team explicitly waives it."
+ },
+ {
+ "code": "MISSING_ACKNOWLEDGEMENT",
+ "severity": "medium",
+ "communicationId": "comm-102",
+ "phase": "payout",
+ "topic": "award settlement instructions",
+ "affectedTeams": [
+ "team-beta"
+ ],
+ "message": "Material communication lacks acknowledgement from one or more recipient teams.",
+ "remediation": "Hold phase transitions until acknowledgement or documented delivery fallback is present."
+ }
+ ],
+ "releaseCriteria": [
+ "Every material sponsor communication reaches all eligible solver teams.",
+ "Rule-affecting messages reference the current immutable rule digest.",
+ "Delivery channels and required attachments are accessible to each recipient.",
+ "Material acknowledgements use fair windows and are logged before phase transitions."
+ ]
+}
\ No newline at end of file
diff --git a/challenge-communication-parity-guard/reports/risky-challenge.md b/challenge-communication-parity-guard/reports/risky-challenge.md
new file mode 100644
index 00000000..0fe875e7
--- /dev/null
+++ b/challenge-communication-parity-guard/reports/risky-challenge.md
@@ -0,0 +1,26 @@
+# Protein binder design bounty
+
+Scenario: risky-challenge
+
+Decision: HOLD
+
+Reviewed 2 communications across 3 eligible teams.
+
+## Findings
+
+- HIGH MATERIAL_AUDIENCE_GAP: Material sponsor communication was not broadcast to every eligible solver team. Affected: team-beta, team-gamma
+- HIGH PRIVATE_MATERIAL_HINT: A material hint or rule-affecting message reached only a subset of teams. Affected: team-alpha
+- MEDIUM STALE_RULE_DIGEST: Material communication references an outdated rule digest. Affected: team-alpha
+- MEDIUM ACCESSIBLE_ATTACHMENT_GAP: Required attachment holdout-hint does not cover recipient accessibility needs. Affected: team-alpha
+- LOW UNFAIR_ACK_WINDOW: Acknowledgement window is too short for timezone-safe team parity. Affected: team-alpha
+- HIGH MATERIAL_AUDIENCE_GAP: Material sponsor communication was not broadcast to every eligible solver team. Affected: team-gamma
+- HIGH PRIVATE_MATERIAL_HINT: A material hint or rule-affecting message reached only a subset of teams. Affected: team-alpha, team-beta
+- LOW UNFAIR_ACK_WINDOW: Acknowledgement window is too short for timezone-safe team parity. Affected: team-alpha, team-beta
+- MEDIUM MISSING_ACKNOWLEDGEMENT: Material communication lacks acknowledgement from one or more recipient teams. Affected: team-beta
+
+## Release Criteria
+
+- Every material sponsor communication reaches all eligible solver teams.
+- Rule-affecting messages reference the current immutable rule digest.
+- Delivery channels and required attachments are accessible to each recipient.
+- Material acknowledgements use fair windows and are logged before phase transitions.
diff --git a/challenge-communication-parity-guard/reports/risky-challenge.svg b/challenge-communication-parity-guard/reports/risky-challenge.svg
new file mode 100644
index 00000000..ce414f25
--- /dev/null
+++ b/challenge-communication-parity-guard/reports/risky-challenge.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/challenge-communication-parity-guard/requirements-map.md b/challenge-communication-parity-guard/requirements-map.md
new file mode 100644
index 00000000..0a6addb1
--- /dev/null
+++ b/challenge-communication-parity-guard/requirements-map.md
@@ -0,0 +1,15 @@
+# Requirements Map
+
+Issue #18 asks for a Scientific Bounty System with challenge posting, submission support, arbitration, reward distribution, IP options, and trust between sponsors and solvers.
+
+| Issue capability | This implementation |
+| --- | --- |
+| Challenge communication trust | Detects material sponsor hints or rule updates that reach only a subset of eligible teams. |
+| Public/private challenge fairness | Requires equal delivery to all eligible teams before submissions, scoring, or payout steps continue. |
+| Timeline and milestone fairness | Flags acknowledgement windows below 24 hours for material communications. |
+| Evaluation criteria/rubric integrity | Requires material communications to reference the current immutable rule digest. |
+| Secure standardized submission process | Holds phase transitions when teams lack acknowledgement or delivery evidence. |
+| Accessibility and global participation | Checks recipient channel coverage and required attachment accessibility needs. |
+| Arbitration and payout safety | Emits deterministic `release`, `revise`, or `hold` decisions with remediation actions. |
+
+The module uses synthetic data only and does not contact payment systems, external APIs, private repositories, or real challenge records.
diff --git a/challenge-communication-parity-guard/sample-data.js b/challenge-communication-parity-guard/sample-data.js
new file mode 100644
index 00000000..980cb9a3
--- /dev/null
+++ b/challenge-communication-parity-guard/sample-data.js
@@ -0,0 +1,117 @@
+const completeChallenge = {
+ challengeId: "sb-comm-001",
+ title: "Protein binder design bounty",
+ currentRuleDigest: "rules-v4-a8f9",
+ clockStartsAt: "2026-06-05T16:00:00Z",
+ teams: [
+ {
+ id: "team-alpha",
+ status: "eligible",
+ timezone: "America/Mexico_City",
+ channels: ["portal", "email"],
+ accessibilityNeeds: ["captions"],
+ },
+ {
+ id: "team-beta",
+ status: "eligible",
+ timezone: "Europe/Berlin",
+ channels: ["portal", "email"],
+ accessibilityNeeds: [],
+ },
+ {
+ id: "team-gamma",
+ status: "eligible",
+ timezone: "Asia/Tokyo",
+ channels: ["portal", "email"],
+ accessibilityNeeds: ["screen-reader"],
+ },
+ ],
+ communications: [
+ {
+ id: "comm-001",
+ phase: "submission",
+ material: true,
+ topic: "benchmark clarification",
+ sentAt: "2026-06-02T16:00:00Z",
+ audience: ["team-alpha", "team-beta", "team-gamma"],
+ channels: ["portal", "email"],
+ ruleDigest: "rules-v4-a8f9",
+ ackDeadlineHours: 36,
+ acknowledgements: {
+ "team-alpha": "2026-06-02T17:00:00Z",
+ "team-beta": "2026-06-02T17:10:00Z",
+ "team-gamma": "2026-06-02T17:20:00Z",
+ },
+ attachments: [
+ {
+ id: "benchmark-notes",
+ required: true,
+ accessibleFormats: ["html", "captions", "screen-reader"],
+ digest: "sha256-aaa111",
+ },
+ ],
+ },
+ {
+ id: "comm-002",
+ phase: "scoring",
+ material: false,
+ topic: "office hours reminder",
+ sentAt: "2026-06-03T15:00:00Z",
+ audience: ["team-alpha", "team-beta", "team-gamma"],
+ channels: ["portal"],
+ ruleDigest: "rules-v4-a8f9",
+ ackDeadlineHours: 24,
+ acknowledgements: {},
+ attachments: [],
+ },
+ ],
+};
+
+const riskyChallenge = {
+ ...completeChallenge,
+ challengeId: "sb-comm-002",
+ communications: [
+ {
+ id: "comm-101",
+ phase: "submission",
+ material: true,
+ topic: "sponsor hint about hidden holdout metric",
+ sentAt: "2026-06-02T22:30:00Z",
+ audience: ["team-alpha"],
+ channels: ["email"],
+ ruleDigest: "rules-v3-legacy",
+ ackDeadlineHours: 8,
+ acknowledgements: {
+ "team-alpha": "2026-06-02T23:00:00Z",
+ },
+ attachments: [
+ {
+ id: "holdout-hint",
+ required: true,
+ accessibleFormats: ["pdf"],
+ digest: "sha256-bbb222",
+ },
+ ],
+ },
+ {
+ id: "comm-102",
+ phase: "payout",
+ material: true,
+ topic: "award settlement instructions",
+ sentAt: "2026-06-04T13:00:00Z",
+ audience: ["team-alpha", "team-beta"],
+ channels: ["portal"],
+ ruleDigest: "rules-v4-a8f9",
+ ackDeadlineHours: 18,
+ acknowledgements: {
+ "team-alpha": "2026-06-04T13:30:00Z",
+ },
+ attachments: [],
+ },
+ ],
+};
+
+module.exports = {
+ completeChallenge,
+ riskyChallenge,
+};
diff --git a/challenge-communication-parity-guard/test.js b/challenge-communication-parity-guard/test.js
new file mode 100644
index 00000000..94fabd98
--- /dev/null
+++ b/challenge-communication-parity-guard/test.js
@@ -0,0 +1,50 @@
+const assert = require("assert");
+
+const { assessCommunicationParity, normalizeChallenge } = require("./index");
+const { completeChallenge, riskyChallenge } = require("./sample-data");
+
+const clean = assessCommunicationParity(completeChallenge);
+assert.strictEqual(clean.decision, "release");
+assert.strictEqual(clean.summary.findings, 0);
+
+const risky = assessCommunicationParity(riskyChallenge);
+assert.strictEqual(risky.decision, "hold");
+assert(risky.findings.some((finding) => finding.code === "MATERIAL_AUDIENCE_GAP"));
+assert(risky.findings.some((finding) => finding.code === "PRIVATE_MATERIAL_HINT"));
+assert(risky.findings.some((finding) => finding.code === "STALE_RULE_DIGEST"));
+assert(risky.findings.some((finding) => finding.code === "ACCESSIBLE_ATTACHMENT_GAP"));
+assert(risky.findings.some((finding) => finding.code === "UNFAIR_ACK_WINDOW"));
+assert(risky.findings.some((finding) => finding.code === "MISSING_ACKNOWLEDGEMENT"));
+
+const missingChannel = assessCommunicationParity({
+ ...completeChallenge,
+ teams: [
+ ...completeChallenge.teams,
+ {
+ id: "team-delta",
+ status: "eligible",
+ timezone: "UTC",
+ channels: ["sms"],
+ accessibilityNeeds: [],
+ },
+ ],
+ communications: [
+ {
+ ...completeChallenge.communications[0],
+ audience: ["team-alpha", "team-beta", "team-gamma", "team-delta"],
+ acknowledgements: {
+ ...completeChallenge.communications[0].acknowledgements,
+ "team-delta": "2026-06-02T17:30:00Z",
+ },
+ },
+ ],
+});
+assert.strictEqual(missingChannel.decision, "revise");
+assert(missingChannel.findings.some((finding) => finding.code === "CHANNEL_COVERAGE_GAP"));
+
+assert.throws(
+ () => normalizeChallenge({ ...completeChallenge, challengeId: "" }),
+ /challengeId must be a non-empty string/
+);
+
+console.log("challenge communication parity guard tests passed");