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
17 changes: 17 additions & 0 deletions challenge-communication-parity-guard/README.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions challenge-communication-parity-guard/demo.js
Original file line number Diff line number Diff line change
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="420" viewBox="0 0 900 420">
<rect width="900" height="420" fill="#0f172a"/>
<text x="42" y="70" fill="#f8fafc" font-family="Arial" font-size="34">Challenge Communication Parity Guard</text>
<text x="42" y="118" fill="#cbd5e1" font-family="Arial" font-size="20">${report.challengeId}</text>
<rect x="42" y="156" width="220" height="82" rx="8" fill="${color}"/>
<text x="68" y="207" fill="#fff" font-family="Arial" font-size="30">${report.decision.toUpperCase()}</text>
<text x="42" y="286" fill="#e2e8f0" font-family="Arial" font-size="22">Findings: ${report.summary.findings}</text>
<text x="42" y="326" fill="#fecaca" font-family="Arial" font-size="20">High: ${report.summary.high}</text>
<text x="172" y="326" fill="#fed7aa" font-family="Arial" font-size="20">Medium: ${report.summary.medium}</text>
<text x="342" y="326" fill="#bfdbfe" font-family="Arial" font-size="20">Low: ${report.summary.low}</text>
<text x="42" y="372" fill="#94a3b8" font-family="Arial" font-size="18">Synthetic data only. No external APIs or private challenge records.</text>
</svg>`;
}

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)`);
}
46 changes: 46 additions & 0 deletions challenge-communication-parity-guard/demo_video.py
Original file line number Diff line number Diff line change
@@ -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}")
249 changes: 249 additions & 0 deletions challenge-communication-parity-guard/index.js
Original file line number Diff line number Diff line change
@@ -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,
};
13 changes: 13 additions & 0 deletions challenge-communication-parity-guard/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading