From 6da263a87bef8d20be93b9e8d1283657affe26c3 Mon Sep 17 00:00:00 2001 From: AlonePenguin <187998801+AlonePenguin@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:46:43 -0400 Subject: [PATCH] Add challenge withdrawal reimbursement guard --- .../README.md | 44 ++ .../demo.js | 110 +++++ .../index.js | 426 ++++++++++++++++++ .../make-demo-video.js | 98 ++++ .../package.json | 15 + .../reports/clean-withdrawal-report.json | 49 ++ .../reports/demo.mp4 | Bin 0 -> 10018 bytes .../reports/risky-withdrawal-handoff.md | 35 ++ .../reports/risky-withdrawal-report.json | 266 +++++++++++ .../reports/withdrawal-dashboard.svg | 29 ++ .../sample-data.js | 274 +++++++++++ .../test.js | 44 ++ .../verify-video.js | 37 ++ 13 files changed, 1427 insertions(+) create mode 100644 challenge-withdrawal-reimbursement-guard/README.md create mode 100644 challenge-withdrawal-reimbursement-guard/demo.js create mode 100644 challenge-withdrawal-reimbursement-guard/index.js create mode 100644 challenge-withdrawal-reimbursement-guard/make-demo-video.js create mode 100644 challenge-withdrawal-reimbursement-guard/package.json create mode 100644 challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json create mode 100644 challenge-withdrawal-reimbursement-guard/reports/demo.mp4 create mode 100644 challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md create mode 100644 challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json create mode 100644 challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg create mode 100644 challenge-withdrawal-reimbursement-guard/sample-data.js create mode 100644 challenge-withdrawal-reimbursement-guard/test.js create mode 100644 challenge-withdrawal-reimbursement-guard/verify-video.js diff --git a/challenge-withdrawal-reimbursement-guard/README.md b/challenge-withdrawal-reimbursement-guard/README.md new file mode 100644 index 00000000..e80a3c5b --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/README.md @@ -0,0 +1,44 @@ +# Challenge Withdrawal Reimbursement Guard + +This self-contained module adds a deterministic closeout guard for sponsor withdrawals, cancellations, and material scope reductions in the SCIBASE Scientific Bounty System. It is scoped to issue #18 and focuses on solver fairness after teams have already started work. + +The guard does not call external APIs, payment processors, live payout systems, private data stores, or credentialed services. Fixtures are synthetic and every check runs with Node built-ins. + +## What It Checks + +- Sponsor cancellation authority and reviewer-ready withdrawal reasons. +- Equal direct notice to every started solver. +- Appeal-window and solver-cost claim instructions in each notice. +- Dispute-hold duration before sponsor refund or challenge closeout. +- Milestone-progress evidence and hashed work artifacts. +- Cost claim evidence for documented solver spend. +- Receipt hashes for non-refundable solver costs. +- Sponsor-funded reimbursement reserve sufficiency. +- Recorded reimbursement decisions against deterministic recommendations. +- IP return and data-destruction attestations before closeout. + +## Local Validation + +```sh +npm --prefix challenge-withdrawal-reimbursement-guard run check +npm --prefix challenge-withdrawal-reimbursement-guard test +npm --prefix challenge-withdrawal-reimbursement-guard run demo +npm --prefix challenge-withdrawal-reimbursement-guard run make-demo-video +npm --prefix challenge-withdrawal-reimbursement-guard run verify-video +``` + +## Generated Artifacts + +Running the demo writes: + +- `reports/clean-withdrawal-report.json` +- `reports/risky-withdrawal-report.json` +- `reports/risky-withdrawal-handoff.md` +- `reports/withdrawal-dashboard.svg` +- `reports/demo.mp4` + +The risky packet intentionally demonstrates release blockers: missing cancellation authority, thin withdrawal reason, unequal solver notice, missing appeal and cost-claim instructions, missing milestone evidence, missing cost evidence, missing non-refundable receipts, incomplete IP/data return attestations, short dispute hold, missing appeal deadline, reimbursement shortfalls, and an underfunded reimbursement reserve. + +## Issue Fit + +This is a distinct Scientific Bounty System slice. It complements the existing intake, rubric readiness, scoring, arbitration, award transparency, appeals, escrow, payout eligibility, sponsor compliance, debrief feedback, onboarding clock parity, communication parity, accessibility/localization, deadline fairness, and amendment-consent work by focusing specifically on cancellation closeout fairness after solvers have already incurred work and costs. diff --git a/challenge-withdrawal-reimbursement-guard/demo.js b/challenge-withdrawal-reimbursement-guard/demo.js new file mode 100644 index 00000000..51636e4b --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/demo.js @@ -0,0 +1,110 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { evaluateChallengeWithdrawal } = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +fs.mkdirSync(reportsDir, { recursive: true }); + +const clean = evaluateChallengeWithdrawal(cleanPacket); +const risky = evaluateChallengeWithdrawal(riskyPacket); + +function writeJson(name, value) { + fs.writeFileSync(path.join(reportsDir, name), `${JSON.stringify(value, null, 2)}\n`); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function findingTable(report) { + return report.findings + .slice(0, 12) + .map((finding) => `| ${finding.severity} | ${finding.code} | ${finding.action} |`) + .join("\n"); +} + +function writeHandoff(report) { + const lines = [ + "# Challenge Withdrawal Reimbursement Handoff", + "", + `Decision: ${report.summary.decision}`, + `Affected solvers: ${report.summary.affectedSolvers}`, + `Recommended reimbursement: $${report.summary.recommendedReimbursementUsd.toFixed(2)}`, + `Funded shortfall: $${report.summary.fundedShortfallUsd.toFixed(2)}`, + `Audit digest: ${report.summary.auditDigest}`, + "", + "## Priority Findings", + "", + "| Severity | Code | Remediation |", + "| --- | --- | --- |", + findingTable(report), + "", + "## Team Recommendations", + "", + ...report.challenges.flatMap((challenge) => [ + `### ${challenge.id}`, + "", + "| Team | Recommended | Recorded | Shortfall | Action |", + "| --- | ---: | ---: | ---: | --- |", + ...challenge.recommendations.map((item) => ( + `| ${item.teamId} | $${item.recommendedUsd.toFixed(2)} | $${item.recordedUsd.toFixed(2)} | $${item.shortfallUsd.toFixed(2)} | ${item.action} |` + )), + "" + ]) + ]; + fs.writeFileSync(path.join(reportsDir, "risky-withdrawal-handoff.md"), `${lines.join("\n")}\n`); +} + +function writeSvg(cleanReport, riskyReport) { + const width = 960; + const height = 540; + const cleanBar = Math.round((cleanReport.summary.recommendedReimbursementUsd / 2600) * 300); + const riskyBar = Math.round((riskyReport.summary.recommendedReimbursementUsd / 2600) * 300); + const shortfallBar = Math.round((riskyReport.summary.fundedShortfallUsd / 2600) * 300); + const rows = riskyReport.findings.slice(0, 8).map((finding, index) => { + const y = 250 + index * 26; + const color = finding.severity === "critical" ? "#991b1b" : finding.severity === "high" ? "#dc2626" : "#d97706"; + return `${escapeXml(finding.code)}`; + }).join("\n"); + + const svg = ` + + + + Challenge withdrawal reimbursement guard + Deterministic closeout review for sponsor cancellations after solvers have started work. + Clean closeout + + + $${cleanReport.summary.recommendedReimbursementUsd.toFixed(2)} + Risky closeout + + + $${riskyReport.summary.recommendedReimbursementUsd.toFixed(2)} + Reserve shortfall + + + $${riskyReport.summary.fundedShortfallUsd.toFixed(2)} + Top risky findings + ${rows} + Decision: ${escapeXml(riskyReport.summary.decision)} | ${riskyReport.summary.auditDigest.slice(0, 28)}... + +`; + fs.writeFileSync(path.join(reportsDir, "withdrawal-dashboard.svg"), svg); +} + +writeJson("clean-withdrawal-report.json", clean); +writeJson("risky-withdrawal-report.json", risky); +writeHandoff(risky); +writeSvg(clean, risky); + +console.log("Wrote challenge withdrawal reimbursement guard reports:"); +console.log(`- ${path.join(reportsDir, "clean-withdrawal-report.json")}`); +console.log(`- ${path.join(reportsDir, "risky-withdrawal-report.json")}`); +console.log(`- ${path.join(reportsDir, "risky-withdrawal-handoff.md")}`); +console.log(`- ${path.join(reportsDir, "withdrawal-dashboard.svg")}`); diff --git a/challenge-withdrawal-reimbursement-guard/index.js b/challenge-withdrawal-reimbursement-guard/index.js new file mode 100644 index 00000000..0987ae0b --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/index.js @@ -0,0 +1,426 @@ +const crypto = require("node:crypto"); + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function stableJson(value) { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function sha256(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex"); +} + +function toDate(value) { + const parsed = new Date(value || ""); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +function hoursBetween(laterValue, earlierValue) { + const later = toDate(laterValue); + const earlier = toDate(earlierValue); + if (!later || !earlier) { + return null; + } + return Math.round((later.getTime() - earlier.getTime()) / (60 * 60 * 1000)); +} + +function daysBetween(laterValue, earlierValue) { + const later = toDate(laterValue); + const earlier = toDate(earlierValue); + if (!later || !earlier) { + return null; + } + return Math.floor((later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000)); +} + +function roundMoney(value) { + return Math.round((Number(value) || 0) * 100) / 100; +} + +function severityRank(severity) { + return { critical: 4, high: 3, medium: 2, low: 1 }[severity] || 0; +} + +function addFinding(findings, severity, code, message, refs, action) { + findings.push({ + severity, + code, + message, + refs: asArray(refs), + action + }); +} + +function normalizeString(value) { + return String(value || "").trim().toLowerCase(); +} + +function noticeByTeam(challenge, teamId) { + return asArray(challenge.communications && challenge.communications.solverNotices) + .find((notice) => normalizeString(notice.teamId) === normalizeString(teamId)); +} + +function reimbursementByTeam(challenge, teamId) { + return asArray(challenge.reimbursements) + .find((payment) => normalizeString(payment.teamId) === normalizeString(teamId)); +} + +function solverStartedBeforeCancellation(solver, cancellation) { + const startedAt = toDate(solver.startedAt || solver.acceptedAt); + const requestedAt = toDate(cancellation && cancellation.requestedAt); + if (!startedAt || !requestedAt) { + return false; + } + return startedAt.getTime() <= requestedAt.getTime(); +} + +function evidenceMissing(record) { + return !record || !record.evidenceHash || String(record.evidenceHash).length < 12; +} + +function receiptMissing(record) { + return !record || !record.receiptHash || String(record.receiptHash).length < 12; +} + +function sumAmounts(records) { + return roundMoney(asArray(records).reduce((total, record) => total + (Number(record.amountUsd) || 0), 0)); +} + +function calculateTeamRecommendation(challenge, solver, policy) { + const bountyAmount = Number(challenge.bounty && challenge.bounty.amountUsd) || 0; + const reimbursementCap = Math.min( + Number(challenge.bounty && challenge.bounty.reimbursementCapUsd) || Number.POSITIVE_INFINITY, + bountyAmount * Number(policy.maxReimbursementPercent || 0.35) + ); + const documentedCosts = sumAmounts(asArray(solver.costClaims).filter((claim) => !evidenceMissing(claim))); + const irreversibleCosts = sumAmounts(asArray(solver.costClaims).filter((claim) => claim.nonRefundable === true && !evidenceMissing(claim))); + const progressPercent = Math.max(0, Math.min(100, Number(solver.milestoneProgressPercent) || 0)); + const milestoneCredit = roundMoney(bountyAmount * (progressPercent / 100) * Number(policy.milestoneCreditRate || 0.18)); + const rawRecommendation = roundMoney(irreversibleCosts + milestoneCredit); + const recommendedUsd = roundMoney(Math.min(rawRecommendation, reimbursementCap)); + + return { + teamId: solver.teamId, + documentedCostsUsd: documentedCosts, + irreversibleCostsUsd: irreversibleCosts, + milestoneCreditUsd: milestoneCredit, + reimbursementCapUsd: roundMoney(reimbursementCap), + recommendedUsd + }; +} + +function evaluateChallenge(challenge, policy, reviewDate, findings) { + const cancellation = challenge.cancellation || {}; + const bountyAmount = Number(challenge.bounty && challenge.bounty.amountUsd) || 0; + const materialReduction = Number(cancellation.materialReductionPercent) || 0; + const isWithdrawal = ["withdrawn", "cancelled", "canceled", "reduced"].includes(normalizeString(challenge.status)) + || ["withdrawal", "cancellation", "material_reduction"].includes(normalizeString(cancellation.type)) + || materialReduction >= Number(policy.materialReductionThresholdPercent || 25); + const startedSolvers = asArray(challenge.solvers) + .filter((solver) => solverStartedBeforeCancellation(solver, cancellation)); + const recommendations = []; + + if (!isWithdrawal) { + return { + id: challenge.id, + status: challenge.status || "active", + action: "not_a_withdrawal", + affectedSolvers: 0, + recommendedReimbursementUsd: 0, + fundedShortfallUsd: 0, + recommendations + }; + } + + if (!cancellation.authorizedBy || challenge.sponsor && challenge.sponsor.cancellationAuthority !== true) { + addFinding( + findings, + "critical", + "CANCELLATION_AUTHORITY_MISSING", + `${challenge.id || "Challenge"} does not show a sponsor-authorized cancellation decision.`, + [challenge.id || "challenge"], + "verify_sponsor_cancellation_authority_before_closeout" + ); + } + + if (!cancellation.reason || String(cancellation.reason).length < 20) { + addFinding( + findings, + "medium", + "WITHDRAWAL_REASON_TOO_THIN", + `${challenge.id || "Challenge"} has no reviewer-ready reason for withdrawal or material reduction.`, + [challenge.id || "challenge"], + "record_specific_withdrawal_reason_for_audit" + ); + } + + if (startedSolvers.length === 0 && materialReduction >= Number(policy.materialReductionThresholdPercent || 25)) { + addFinding( + findings, + "medium", + "MATERIAL_REDUCTION_WITHOUT_SOLVER_SCAN", + `${challenge.id || "Challenge"} is materially reduced but has no solver-start scan attached.`, + [challenge.id || "challenge"], + "attach_solver_start_scan_before_scope_reduction" + ); + } + + const directNotices = startedSolvers.map((solver) => noticeByTeam(challenge, solver.teamId)).filter(Boolean); + const earliestNotice = directNotices + .map((notice) => notice.sentAt) + .filter(Boolean) + .sort()[0] || (challenge.communications && challenge.communications.sponsorNoticeAt); + + for (const solver of startedSolvers) { + const refs = [challenge.id || "challenge", solver.teamId || "solver"]; + const notice = noticeByTeam(challenge, solver.teamId); + + if (!notice || !notice.sentAt) { + addFinding( + findings, + "high", + "SOLVER_WITHDRAWAL_NOTICE_MISSING", + `${solver.teamId || "Solver"} has no direct withdrawal notice.`, + refs, + "send_equal_withdrawal_notice_to_all_started_solvers" + ); + } else { + const lag = hoursBetween(notice.sentAt, earliestNotice); + if (lag !== null && lag > Number(policy.equalNoticeToleranceHours || 12)) { + addFinding( + findings, + "high", + "NOTICE_PARITY_GAP", + `${solver.teamId || "Solver"} received withdrawal notice ${lag} hours after the first notified solver.`, + refs, + "restart_closeout_clock_after_equal_solver_notice" + ); + } + if (notice.includesAppealWindow !== true) { + addFinding( + findings, + "medium", + "APPEAL_WINDOW_NOT_IN_NOTICE", + `${solver.teamId || "Solver"} notice omits the appeal window.`, + refs, + "include_appeal_deadline_in_solver_notice" + ); + } + if (notice.includesCostClaimLink !== true) { + addFinding( + findings, + "high", + "COST_CLAIM_PATH_NOT_IN_NOTICE", + `${solver.teamId || "Solver"} notice omits the cost-claim path.`, + refs, + "include_solver_cost_claim_path_in_notice" + ); + } + } + + if (!solver.milestoneProgressPercent && asArray(solver.submittedMilestones).length === 0) { + addFinding( + findings, + "medium", + "MILESTONE_PROGRESS_EVIDENCE_MISSING", + `${solver.teamId || "Solver"} has no milestone-progress evidence for reimbursement review.`, + refs, + "attach_milestone_progress_evidence_before_reimbursement_decision" + ); + } + + for (const milestone of asArray(solver.submittedMilestones)) { + if (evidenceMissing(milestone)) { + addFinding( + findings, + "high", + "MILESTONE_EVIDENCE_HASH_MISSING", + `${solver.teamId || "Solver"} milestone ${milestone.id || "unknown"} lacks an evidence hash.`, + refs, + "hash_milestone_evidence_before_solver_cost_review" + ); + } + } + + for (const claim of asArray(solver.costClaims)) { + if (evidenceMissing(claim)) { + addFinding( + findings, + "high", + "COST_CLAIM_EVIDENCE_MISSING", + `${solver.teamId || "Solver"} cost claim ${claim.id || "unknown"} lacks evidence.`, + refs, + "attach_cost_claim_evidence_before_reimbursement" + ); + } + if (claim.nonRefundable === true && policy.irreversibleSpendRequiresReceipt !== false && receiptMissing(claim)) { + addFinding( + findings, + "high", + "NONREFUNDABLE_RECEIPT_MISSING", + `${solver.teamId || "Solver"} non-refundable cost claim ${claim.id || "unknown"} lacks a receipt hash.`, + refs, + "attach_receipt_hash_for_nonrefundable_solver_spend" + ); + } + } + + if (!solver.ipReturn || !solver.ipReturn.returnedAt || !solver.ipReturn.dataDestroyedAt || !solver.ipReturn.attestationHash) { + addFinding( + findings, + "medium", + "IP_DATA_RETURN_ATTESTATION_MISSING", + `${solver.teamId || "Solver"} has no complete IP return and data-destruction attestation.`, + refs, + "collect_ip_return_and_data_destruction_attestation" + ); + } + + const recommendation = calculateTeamRecommendation(challenge, solver, policy); + const recorded = reimbursementByTeam(challenge, solver.teamId); + const recordedAmount = Number(recorded && recorded.amountUsd) || 0; + recommendation.recordedUsd = roundMoney(recordedAmount); + recommendation.shortfallUsd = roundMoney(Math.max(0, recommendation.recommendedUsd - recordedAmount)); + recommendation.action = recommendation.shortfallUsd > 0 ? "hold_and_reimburse_solver_costs" : "release_reimbursement_closeout"; + recommendations.push(recommendation); + + if (recommendation.recommendedUsd > 0 && !recorded) { + addFinding( + findings, + "high", + "REIMBURSEMENT_DECISION_MISSING", + `${solver.teamId || "Solver"} has an eligible reimbursement recommendation but no recorded decision.`, + refs, + "record_solver_reimbursement_decision_before_challenge_closeout" + ); + } else if (recommendation.shortfallUsd > 0) { + addFinding( + findings, + "high", + "REIMBURSEMENT_SHORTFALL", + `${solver.teamId || "Solver"} reimbursement is short by $${recommendation.shortfallUsd.toFixed(2)}.`, + refs, + "fund_reimbursement_shortfall_or_escalate_dispute" + ); + } + } + + const holdDays = daysBetween(cancellation.disputeHoldUntil, cancellation.requestedAt); + if (holdDays === null || holdDays < Number(policy.disputeHoldDays || 7)) { + addFinding( + findings, + "critical", + "DISPUTE_HOLD_WINDOW_TOO_SHORT", + `${challenge.id || "Challenge"} has a dispute hold of ${holdDays === null ? "none" : `${holdDays} days`}.`, + [challenge.id || "challenge"], + "extend_dispute_hold_before_refund_or_closeout" + ); + } + + if (!cancellation.appealDeadline) { + addFinding( + findings, + "high", + "APPEAL_DEADLINE_MISSING", + `${challenge.id || "Challenge"} has no solver appeal deadline.`, + [challenge.id || "challenge"], + "set_solver_appeal_deadline_before_withdrawal_closeout" + ); + } + + const totalRecommended = roundMoney(recommendations.reduce((total, item) => total + item.recommendedUsd, 0)); + const reserve = Number(challenge.sponsor && challenge.sponsor.reimbursementReserveUsd) || 0; + const fundedShortfall = roundMoney(Math.max(0, totalRecommended - reserve)); + + if (policy.requireSponsorFundedReserve !== false && fundedShortfall > 0) { + addFinding( + findings, + "critical", + "REIMBURSEMENT_RESERVE_SHORTFALL", + `${challenge.id || "Challenge"} reserve is short by $${fundedShortfall.toFixed(2)} for solver reimbursement.`, + [challenge.id || "challenge"], + "fund_solver_reimbursement_reserve_before_releasing_sponsor_refund" + ); + } + + const closeoutAction = recommendations.some((item) => item.shortfallUsd > 0) || fundedShortfall > 0 + ? "hold_for_solver_reimbursement" + : "release_withdrawal_closeout"; + + return { + id: challenge.id, + status: challenge.status || "withdrawn", + bountyAmountUsd: roundMoney(bountyAmount), + action: closeoutAction, + affectedSolvers: startedSolvers.length, + recommendedReimbursementUsd: totalRecommended, + fundedShortfallUsd: fundedShortfall, + recommendations + }; +} + +function evaluateChallengeWithdrawal(packet) { + const findings = []; + const reviewDate = packet.reviewDate || new Date().toISOString().slice(0, 10); + const policy = { + equalNoticeToleranceHours: 12, + disputeHoldDays: 7, + maxReimbursementPercent: 0.35, + materialReductionThresholdPercent: 25, + milestoneCreditRate: 0.18, + irreversibleSpendRequiresReceipt: true, + requireSponsorFundedReserve: true, + ...(packet.policy || {}) + }; + const challengeSummaries = asArray(packet.challenges).map((challenge) => ( + evaluateChallenge(challenge, policy, reviewDate, findings) + )); + const criticalFindings = findings.filter((finding) => finding.severity === "critical").length; + const highOrCriticalFindings = findings.filter((finding) => severityRank(finding.severity) >= severityRank("high")).length; + const recommendedReimbursementUsd = roundMoney(challengeSummaries.reduce((total, item) => total + item.recommendedReimbursementUsd, 0)); + const fundedShortfallUsd = roundMoney(challengeSummaries.reduce((total, item) => total + item.fundedShortfallUsd, 0)); + const affectedSolvers = challengeSummaries.reduce((total, item) => total + item.affectedSolvers, 0); + let decision = "release_withdrawal_closeout"; + + if (criticalFindings > 0 || fundedShortfallUsd > 0) { + decision = "escalate_withdrawal_dispute"; + } else if (highOrCriticalFindings > 0) { + decision = "hold_for_reimbursement_review"; + } + + const auditSubject = { + reviewDate, + policy, + challengeSummaries, + findingCodes: findings.map((finding) => finding.code).sort() + }; + + return { + summary: { + decision, + challengeCount: challengeSummaries.length, + affectedSolvers, + recommendedReimbursementUsd, + fundedShortfallUsd, + findingCount: findings.length, + criticalFindings, + highOrCriticalFindings, + auditDigest: `sha256:${sha256(auditSubject)}` + }, + challenges: challengeSummaries, + findings: findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.code.localeCompare(b.code)) + }; +} + +module.exports = { + evaluateChallengeWithdrawal, + sha256 +}; diff --git a/challenge-withdrawal-reimbursement-guard/make-demo-video.js b/challenge-withdrawal-reimbursement-guard/make-demo-video.js new file mode 100644 index 00000000..67f755db --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/make-demo-video.js @@ -0,0 +1,98 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); +const { evaluateChallengeWithdrawal } = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const reportsDir = path.join(__dirname, "reports"); +const framesDir = path.join(reportsDir, "frames"); +fs.mkdirSync(framesDir, { recursive: true }); + +const clean = evaluateChallengeWithdrawal(cleanPacket); +const risky = evaluateChallengeWithdrawal(riskyPacket); +const width = 960; +const height = 540; +const frames = 72; +const fps = 18; + +function setPixel(buffer, x, y, r, g, b) { + if (x < 0 || y < 0 || x >= width || y >= height) { + return; + } + const offset = (y * width + x) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; +} + +function fillRect(buffer, x, y, w, h, r, g, b) { + for (let row = y; row < y + h; row += 1) { + for (let col = x; col < x + w; col += 1) { + setPixel(buffer, col, row, r, g, b); + } + } +} + +function drawColumns(buffer, x, baseline, count, color) { + for (let index = 0; index < count; index += 1) { + const barHeight = 26 + (index % 6) * 17; + fillRect(buffer, x + index * 26, baseline - barHeight, 18, barHeight, color[0], color[1], color[2]); + } +} + +function writeFrame(index, progress) { + const buffer = Buffer.alloc(width * height * 3, 248); + fillRect(buffer, 0, 0, width, height, 248, 250, 252); + fillRect(buffer, 48, 44, 864, 452, 255, 255, 255); + fillRect(buffer, 48, 44, 864, 8, 15, 23, 42); + + const cleanWidth = Math.floor(300 * Math.min(1, progress * 1.5) * (clean.summary.recommendedReimbursementUsd / 2600)); + const riskyWidth = Math.floor(300 * Math.max(0, (progress - 0.08) * 1.4) * (risky.summary.recommendedReimbursementUsd / 2600)); + const shortfallWidth = Math.floor(300 * Math.max(0, (progress - 0.18) * 1.3) * (risky.summary.fundedShortfallUsd / 2600)); + + fillRect(buffer, 96, 126, 300, 42, 226, 232, 240); + fillRect(buffer, 96, 126, cleanWidth, 42, 16, 185, 129); + fillRect(buffer, 96, 222, 300, 42, 226, 232, 240); + fillRect(buffer, 96, 222, riskyWidth, 42, 239, 68, 68); + fillRect(buffer, 96, 318, 300, 42, 226, 232, 240); + fillRect(buffer, 96, 318, shortfallWidth, 42, 245, 158, 11); + + for (let i = 0; i < risky.summary.affectedSolvers; i += 1) { + fillRect(buffer, 112 + i * 78, 404, 52, 52, 99, 102, 241); + fillRect(buffer, 122 + i * 78, 416, 32, 8, 255, 255, 255); + fillRect(buffer, 122 + i * 78, 434, 32, 8, 255, 255, 255); + } + + drawColumns(buffer, 536, 408, Math.min(12, risky.summary.findingCount), [220, 38, 38]); + drawColumns(buffer, 536, 210, clean.summary.findingCount, [16, 185, 129]); + fillRect(buffer, 536, 436, Math.floor(310 * progress), 14, 37, 99, 235); + + const header = Buffer.from(`P6\n${width} ${height}\n255\n`, "ascii"); + fs.writeFileSync(path.join(framesDir, `frame-${String(index).padStart(3, "0")}.ppm`), Buffer.concat([header, buffer])); +} + +for (let index = 0; index < frames; index += 1) { + writeFrame(index, index / (frames - 1)); +} + +const output = path.join(reportsDir, "demo.mp4"); +const result = spawnSync(process.env.FFMPEG_PATH || "ffmpeg", [ + "-y", + "-framerate", + String(fps), + "-i", + path.join(framesDir, "frame-%03d.ppm"), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + output +], { stdio: "inherit" }); + +fs.rmSync(framesDir, { recursive: true, force: true }); + +if (result.status !== 0) { + process.exit(result.status || 1); +} + +console.log(`Wrote ${output}`); diff --git a/challenge-withdrawal-reimbursement-guard/package.json b/challenge-withdrawal-reimbursement-guard/package.json new file mode 100644 index 00000000..45f377ee --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "challenge-withdrawal-reimbursement-guard", + "version": "1.0.0", + "description": "Dependency-free challenge withdrawal and solver-cost reimbursement guard for SCIBASE scientific bounty workflows.", + "main": "index.js", + "scripts": { + "check": "node test.js", + "test": "node test.js", + "demo": "node demo.js", + "make-demo-video": "node make-demo-video.js", + "verify-video": "node verify-video.js" + }, + "license": "MIT", + "private": true +} diff --git a/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json b/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json new file mode 100644 index 00000000..a4a2adb7 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/clean-withdrawal-report.json @@ -0,0 +1,49 @@ +{ + "summary": { + "decision": "release_withdrawal_closeout", + "challengeCount": 1, + "affectedSolvers": 2, + "recommendedReimbursementUsd": 1076, + "fundedShortfallUsd": 0, + "findingCount": 0, + "criticalFindings": 0, + "highOrCriticalFindings": 0, + "auditDigest": "sha256:11141c6fc5ef8c4eb1259853baad8680c7925329d6be3f4dfa10c347fb95d4c4" + }, + "challenges": [ + { + "id": "BNTY-WITHDRAW-ALPHA", + "status": "withdrawn", + "bountyAmountUsd": 6000, + "action": "release_withdrawal_closeout", + "affectedSolvers": 2, + "recommendedReimbursementUsd": 1076, + "fundedShortfallUsd": 0, + "recommendations": [ + { + "teamId": "team-curie", + "documentedCostsUsd": 180, + "irreversibleCostsUsd": 180, + "milestoneCreditUsd": 453.6, + "reimbursementCapUsd": 2100, + "recommendedUsd": 633.6, + "recordedUsd": 633.6, + "shortfallUsd": 0, + "action": "release_reimbursement_closeout" + }, + { + "teamId": "team-noether", + "documentedCostsUsd": 140, + "irreversibleCostsUsd": 140, + "milestoneCreditUsd": 302.4, + "reimbursementCapUsd": 2100, + "recommendedUsd": 442.4, + "recordedUsd": 442.4, + "shortfallUsd": 0, + "action": "release_reimbursement_closeout" + } + ] + } + ], + "findings": [] +} diff --git a/challenge-withdrawal-reimbursement-guard/reports/demo.mp4 b/challenge-withdrawal-reimbursement-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2711fe84a1d3c98bfbeef81daafeafffc9fce306 GIT binary patch literal 10018 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 D1sMe3 literal 0 HcmV?d00001 diff --git a/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md new file mode 100644 index 00000000..3bd0796f --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-handoff.md @@ -0,0 +1,35 @@ +# Challenge Withdrawal Reimbursement Handoff + +Decision: escalate_withdrawal_dispute +Affected solvers: 3 +Recommended reimbursement: $2586.00 +Funded shortfall: $2286.00 +Audit digest: sha256:af101fe0baa90544d8acc9069ccfa1524ccfc6c133806eeff1bb088088a48cb1 + +## Priority Findings + +| Severity | Code | Remediation | +| --- | --- | --- | +| critical | CANCELLATION_AUTHORITY_MISSING | verify_sponsor_cancellation_authority_before_closeout | +| critical | DISPUTE_HOLD_WINDOW_TOO_SHORT | extend_dispute_hold_before_refund_or_closeout | +| critical | REIMBURSEMENT_RESERVE_SHORTFALL | fund_solver_reimbursement_reserve_before_releasing_sponsor_refund | +| high | APPEAL_DEADLINE_MISSING | set_solver_appeal_deadline_before_withdrawal_closeout | +| high | COST_CLAIM_EVIDENCE_MISSING | attach_cost_claim_evidence_before_reimbursement | +| high | COST_CLAIM_PATH_NOT_IN_NOTICE | include_solver_cost_claim_path_in_notice | +| high | MILESTONE_EVIDENCE_HASH_MISSING | hash_milestone_evidence_before_solver_cost_review | +| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend | +| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend | +| high | NONREFUNDABLE_RECEIPT_MISSING | attach_receipt_hash_for_nonrefundable_solver_spend | +| high | NOTICE_PARITY_GAP | restart_closeout_clock_after_equal_solver_notice | +| high | REIMBURSEMENT_DECISION_MISSING | record_solver_reimbursement_decision_before_challenge_closeout | + +## Team Recommendations + +### BNTY-WITHDRAW-RISK + +| Team | Recommended | Recorded | Shortfall | Action | +| --- | ---: | ---: | ---: | --- | +| team-lovelace | $1449.00 | $500.00 | $949.00 | hold_and_reimburse_solver_costs | +| team-hopper | $634.00 | $0.00 | $634.00 | hold_and_reimburse_solver_costs | +| team-johnson | $503.00 | $0.00 | $503.00 | hold_and_reimburse_solver_costs | + diff --git a/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json new file mode 100644 index 00000000..1c304c62 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/risky-withdrawal-report.json @@ -0,0 +1,266 @@ +{ + "summary": { + "decision": "escalate_withdrawal_dispute", + "challengeCount": 1, + "affectedSolvers": 3, + "recommendedReimbursementUsd": 2586, + "fundedShortfallUsd": 2286, + "findingCount": 21, + "criticalFindings": 3, + "highOrCriticalFindings": 15, + "auditDigest": "sha256:af101fe0baa90544d8acc9069ccfa1524ccfc6c133806eeff1bb088088a48cb1" + }, + "challenges": [ + { + "id": "BNTY-WITHDRAW-RISK", + "status": "canceled", + "bountyAmountUsd": 9000, + "action": "hold_for_solver_reimbursement", + "affectedSolvers": 3, + "recommendedReimbursementUsd": 2586, + "fundedShortfallUsd": 2286, + "recommendations": [ + { + "teamId": "team-lovelace", + "documentedCostsUsd": 720, + "irreversibleCostsUsd": 720, + "milestoneCreditUsd": 729, + "reimbursementCapUsd": 2600, + "recommendedUsd": 1449, + "recordedUsd": 500, + "shortfallUsd": 949, + "action": "hold_and_reimburse_solver_costs" + }, + { + "teamId": "team-hopper", + "documentedCostsUsd": 310, + "irreversibleCostsUsd": 310, + "milestoneCreditUsd": 324, + "reimbursementCapUsd": 2600, + "recommendedUsd": 634, + "recordedUsd": 0, + "shortfallUsd": 634, + "action": "hold_and_reimburse_solver_costs" + }, + { + "teamId": "team-johnson", + "documentedCostsUsd": 260, + "irreversibleCostsUsd": 260, + "milestoneCreditUsd": 243, + "reimbursementCapUsd": 2600, + "recommendedUsd": 503, + "recordedUsd": 0, + "shortfallUsd": 503, + "action": "hold_and_reimburse_solver_costs" + } + ] + } + ], + "findings": [ + { + "severity": "critical", + "code": "CANCELLATION_AUTHORITY_MISSING", + "message": "BNTY-WITHDRAW-RISK does not show a sponsor-authorized cancellation decision.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "verify_sponsor_cancellation_authority_before_closeout" + }, + { + "severity": "critical", + "code": "DISPUTE_HOLD_WINDOW_TOO_SHORT", + "message": "BNTY-WITHDRAW-RISK has a dispute hold of 4 days.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "extend_dispute_hold_before_refund_or_closeout" + }, + { + "severity": "critical", + "code": "REIMBURSEMENT_RESERVE_SHORTFALL", + "message": "BNTY-WITHDRAW-RISK reserve is short by $2286.00 for solver reimbursement.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "fund_solver_reimbursement_reserve_before_releasing_sponsor_refund" + }, + { + "severity": "high", + "code": "APPEAL_DEADLINE_MISSING", + "message": "BNTY-WITHDRAW-RISK has no solver appeal deadline.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "set_solver_appeal_deadline_before_withdrawal_closeout" + }, + { + "severity": "high", + "code": "COST_CLAIM_EVIDENCE_MISSING", + "message": "team-lovelace cost claim annotation-vendor lacks evidence.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "attach_cost_claim_evidence_before_reimbursement" + }, + { + "severity": "high", + "code": "COST_CLAIM_PATH_NOT_IN_NOTICE", + "message": "team-lovelace notice omits the cost-claim path.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "include_solver_cost_claim_path_in_notice" + }, + { + "severity": "high", + "code": "MILESTONE_EVIDENCE_HASH_MISSING", + "message": "team-lovelace milestone baseline-model lacks an evidence hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "hash_milestone_evidence_before_solver_cost_review" + }, + { + "severity": "high", + "code": "NONREFUNDABLE_RECEIPT_MISSING", + "message": "team-lovelace non-refundable cost claim gpu-reservation lacks a receipt hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "attach_receipt_hash_for_nonrefundable_solver_spend" + }, + { + "severity": "high", + "code": "NONREFUNDABLE_RECEIPT_MISSING", + "message": "team-lovelace non-refundable cost claim annotation-vendor lacks a receipt hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "attach_receipt_hash_for_nonrefundable_solver_spend" + }, + { + "severity": "high", + "code": "NONREFUNDABLE_RECEIPT_MISSING", + "message": "team-hopper non-refundable cost claim secure-workspace lacks a receipt hash.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "attach_receipt_hash_for_nonrefundable_solver_spend" + }, + { + "severity": "high", + "code": "NOTICE_PARITY_GAP", + "message": "team-hopper received withdrawal notice 20 hours after the first notified solver.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "restart_closeout_clock_after_equal_solver_notice" + }, + { + "severity": "high", + "code": "REIMBURSEMENT_DECISION_MISSING", + "message": "team-hopper has an eligible reimbursement recommendation but no recorded decision.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "record_solver_reimbursement_decision_before_challenge_closeout" + }, + { + "severity": "high", + "code": "REIMBURSEMENT_DECISION_MISSING", + "message": "team-johnson has an eligible reimbursement recommendation but no recorded decision.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-johnson" + ], + "action": "record_solver_reimbursement_decision_before_challenge_closeout" + }, + { + "severity": "high", + "code": "REIMBURSEMENT_SHORTFALL", + "message": "team-lovelace reimbursement is short by $949.00.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "fund_reimbursement_shortfall_or_escalate_dispute" + }, + { + "severity": "high", + "code": "SOLVER_WITHDRAWAL_NOTICE_MISSING", + "message": "team-johnson has no direct withdrawal notice.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-johnson" + ], + "action": "send_equal_withdrawal_notice_to_all_started_solvers" + }, + { + "severity": "medium", + "code": "APPEAL_WINDOW_NOT_IN_NOTICE", + "message": "team-lovelace notice omits the appeal window.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "include_appeal_deadline_in_solver_notice" + }, + { + "severity": "medium", + "code": "APPEAL_WINDOW_NOT_IN_NOTICE", + "message": "team-hopper notice omits the appeal window.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "include_appeal_deadline_in_solver_notice" + }, + { + "severity": "medium", + "code": "IP_DATA_RETURN_ATTESTATION_MISSING", + "message": "team-lovelace has no complete IP return and data-destruction attestation.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-lovelace" + ], + "action": "collect_ip_return_and_data_destruction_attestation" + }, + { + "severity": "medium", + "code": "IP_DATA_RETURN_ATTESTATION_MISSING", + "message": "team-hopper has no complete IP return and data-destruction attestation.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-hopper" + ], + "action": "collect_ip_return_and_data_destruction_attestation" + }, + { + "severity": "medium", + "code": "IP_DATA_RETURN_ATTESTATION_MISSING", + "message": "team-johnson has no complete IP return and data-destruction attestation.", + "refs": [ + "BNTY-WITHDRAW-RISK", + "team-johnson" + ], + "action": "collect_ip_return_and_data_destruction_attestation" + }, + { + "severity": "medium", + "code": "WITHDRAWAL_REASON_TOO_THIN", + "message": "BNTY-WITHDRAW-RISK has no reviewer-ready reason for withdrawal or material reduction.", + "refs": [ + "BNTY-WITHDRAW-RISK" + ], + "action": "record_specific_withdrawal_reason_for_audit" + } + ] +} diff --git a/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg b/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg new file mode 100644 index 00000000..fe101681 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/reports/withdrawal-dashboard.svg @@ -0,0 +1,29 @@ + + + + + Challenge withdrawal reimbursement guard + Deterministic closeout review for sponsor cancellations after solvers have started work. + Clean closeout + + + $1076.00 + Risky closeout + + + $2586.00 + Reserve shortfall + + + $2286.00 + Top risky findings + CANCELLATION_AUTHORITY_MISSING +DISPUTE_HOLD_WINDOW_TOO_SHORT +REIMBURSEMENT_RESERVE_SHORTFALL +APPEAL_DEADLINE_MISSING +COST_CLAIM_EVIDENCE_MISSING +COST_CLAIM_PATH_NOT_IN_NOTICE +MILESTONE_EVIDENCE_HASH_MISSING +NONREFUNDABLE_RECEIPT_MISSING + Decision: escalate_withdrawal_dispute | sha256:af101fe0baa90544d8acc... + diff --git a/challenge-withdrawal-reimbursement-guard/sample-data.js b/challenge-withdrawal-reimbursement-guard/sample-data.js new file mode 100644 index 00000000..f2482b72 --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/sample-data.js @@ -0,0 +1,274 @@ +const cleanPacket = { + reviewDate: "2026-06-01", + policy: { + equalNoticeToleranceHours: 12, + disputeHoldDays: 10, + maxReimbursementPercent: 0.35, + materialReductionThresholdPercent: 25, + milestoneCreditRate: 0.18, + irreversibleSpendRequiresReceipt: true, + requireSponsorFundedReserve: true + }, + challenges: [ + { + id: "BNTY-WITHDRAW-ALPHA", + title: "Federated reproducibility benchmark", + status: "withdrawn", + sponsor: { + id: "sponsor-lab-a", + cancellationAuthority: true, + reimbursementReserveUsd: 2200 + }, + bounty: { + amountUsd: 6000, + reimbursementCapUsd: 2100 + }, + cancellation: { + type: "withdrawal", + requestedAt: "2026-05-28T15:00:00Z", + effectiveAt: "2026-06-07T15:00:00Z", + reason: "Sponsor lost access to the proprietary validation corpus and withdrew before finalist scoring.", + authorizedBy: "sponsor-owner-17", + materialReductionPercent: 100, + appealDeadline: "2026-06-06T15:00:00Z", + disputeHoldUntil: "2026-06-08T15:00:00Z" + }, + communications: { + sponsorNoticeAt: "2026-05-28T15:10:00Z", + solverNotices: [ + { + teamId: "team-curie", + sentAt: "2026-05-28T15:15:00Z", + channel: "platform", + includesAppealWindow: true, + includesCostClaimLink: true + }, + { + teamId: "team-noether", + sentAt: "2026-05-28T16:00:00Z", + channel: "platform", + includesAppealWindow: true, + includesCostClaimLink: true + } + ] + }, + solvers: [ + { + teamId: "team-curie", + acceptedAt: "2026-05-20T12:00:00Z", + startedAt: "2026-05-21T09:00:00Z", + milestoneProgressPercent: 42, + submittedMilestones: [ + { id: "design-review", percent: 25, evidenceHash: "sha256:curie-design-review-0a1b2c3d4e" }, + { id: "baseline-run", percent: 17, evidenceHash: "sha256:curie-baseline-run-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "cloud-prep", + amountUsd: 180, + category: "compute", + incurredAt: "2026-05-25", + nonRefundable: true, + evidenceHash: "sha256:curie-cloud-evidence-0a1b2c3d4e", + receiptHash: "sha256:curie-cloud-receipt-0a1b2c3d4e" + } + ], + ipReturn: { + returnedAt: "2026-05-30T12:00:00Z", + dataDestroyedAt: "2026-05-30T12:15:00Z", + attestationHash: "sha256:curie-return-attestation-0a1b2c3d4e" + } + }, + { + teamId: "team-noether", + acceptedAt: "2026-05-22T13:30:00Z", + startedAt: "2026-05-23T10:00:00Z", + milestoneProgressPercent: 28, + submittedMilestones: [ + { id: "replication-plan", percent: 28, evidenceHash: "sha256:noether-plan-evidence-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "dataset-access", + amountUsd: 140, + category: "data", + incurredAt: "2026-05-24", + nonRefundable: true, + evidenceHash: "sha256:noether-data-evidence-0a1b2c3d4e", + receiptHash: "sha256:noether-data-receipt-0a1b2c3d4e" + } + ], + ipReturn: { + returnedAt: "2026-05-30T13:00:00Z", + dataDestroyedAt: "2026-05-30T13:20:00Z", + attestationHash: "sha256:noether-return-attestation-0a1b2c3d4e" + } + } + ], + reimbursements: [ + { + teamId: "team-curie", + amountUsd: 633.60, + approvedBy: "bounty-ops", + approvedAt: "2026-05-31T10:00:00Z" + }, + { + teamId: "team-noether", + amountUsd: 442.40, + approvedBy: "bounty-ops", + approvedAt: "2026-05-31T10:05:00Z" + } + ] + } + ] +}; + +const riskyPacket = { + reviewDate: "2026-06-01", + policy: { + equalNoticeToleranceHours: 8, + disputeHoldDays: 10, + maxReimbursementPercent: 0.35, + materialReductionThresholdPercent: 25, + milestoneCreditRate: 0.18, + irreversibleSpendRequiresReceipt: true, + requireSponsorFundedReserve: true + }, + challenges: [ + { + id: "BNTY-WITHDRAW-RISK", + title: "Private clinical signal challenge", + status: "canceled", + sponsor: { + id: "sponsor-clinic-x", + cancellationAuthority: false, + reimbursementReserveUsd: 300 + }, + bounty: { + amountUsd: 9000, + reimbursementCapUsd: 2600 + }, + cancellation: { + type: "cancellation", + requestedAt: "2026-05-29T11:00:00Z", + effectiveAt: "2026-05-30T11:00:00Z", + reason: "Budget changed", + materialReductionPercent: 100, + disputeHoldUntil: "2026-06-02T11:00:00Z" + }, + communications: { + sponsorNoticeAt: "2026-05-29T12:00:00Z", + solverNotices: [ + { + teamId: "team-lovelace", + sentAt: "2026-05-29T13:00:00Z", + channel: "email", + includesAppealWindow: false, + includesCostClaimLink: false + }, + { + teamId: "team-hopper", + sentAt: "2026-05-30T09:00:00Z", + channel: "email", + includesAppealWindow: false, + includesCostClaimLink: true + } + ] + }, + solvers: [ + { + teamId: "team-lovelace", + acceptedAt: "2026-05-20T12:00:00Z", + startedAt: "2026-05-21T08:00:00Z", + milestoneProgressPercent: 45, + submittedMilestones: [ + { id: "baseline-model", percent: 35, evidenceHash: "" }, + { id: "validation-report", percent: 10, evidenceHash: "sha256:lovelace-report-evidence-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "gpu-reservation", + amountUsd: 720, + category: "compute", + incurredAt: "2026-05-24", + nonRefundable: true, + evidenceHash: "sha256:lovelace-gpu-evidence-0a1b2c3d4e", + receiptHash: "" + }, + { + id: "annotation-vendor", + amountUsd: 420, + category: "annotation", + incurredAt: "2026-05-25", + nonRefundable: true, + evidenceHash: "", + receiptHash: "" + } + ], + ipReturn: { + returnedAt: "", + dataDestroyedAt: "", + attestationHash: "" + } + }, + { + teamId: "team-hopper", + acceptedAt: "2026-05-22T10:00:00Z", + startedAt: "2026-05-23T09:00:00Z", + milestoneProgressPercent: 20, + submittedMilestones: [], + costClaims: [ + { + id: "secure-workspace", + amountUsd: 310, + category: "workspace", + incurredAt: "2026-05-24", + nonRefundable: true, + evidenceHash: "sha256:hopper-workspace-evidence-0a1b2c3d4e", + receiptHash: "" + } + ], + ipReturn: null + }, + { + teamId: "team-johnson", + acceptedAt: "2026-05-23T10:00:00Z", + startedAt: "2026-05-24T09:00:00Z", + milestoneProgressPercent: 15, + submittedMilestones: [ + { id: "error-analysis", percent: 15, evidenceHash: "sha256:johnson-error-analysis-0a1b2c3d4e" } + ], + costClaims: [ + { + id: "dataset-license", + amountUsd: 260, + category: "data", + incurredAt: "2026-05-25", + nonRefundable: true, + evidenceHash: "sha256:johnson-license-evidence-0a1b2c3d4e", + receiptHash: "sha256:johnson-license-receipt-0a1b2c3d4e" + } + ], + ipReturn: { + returnedAt: "2026-05-31T12:00:00Z", + dataDestroyedAt: "", + attestationHash: "" + } + } + ], + reimbursements: [ + { + teamId: "team-lovelace", + amountUsd: 500, + approvedBy: "sponsor-admin", + approvedAt: "2026-05-30T12:00:00Z" + } + ] + } + ] +}; + +module.exports = { + cleanPacket, + riskyPacket +}; diff --git a/challenge-withdrawal-reimbursement-guard/test.js b/challenge-withdrawal-reimbursement-guard/test.js new file mode 100644 index 00000000..c376b33a --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/test.js @@ -0,0 +1,44 @@ +const assert = require("node:assert/strict"); +const { evaluateChallengeWithdrawal, sha256 } = require("./index"); +const { cleanPacket, riskyPacket } = require("./sample-data"); + +const clean = evaluateChallengeWithdrawal(cleanPacket); +assert.equal(clean.summary.decision, "release_withdrawal_closeout"); +assert.equal(clean.summary.findingCount, 0); +assert.equal(clean.summary.challengeCount, 1); +assert.equal(clean.summary.affectedSolvers, 2); +assert.equal(clean.summary.recommendedReimbursementUsd, 1076); +assert.equal(clean.summary.fundedShortfallUsd, 0); +assert.ok(clean.summary.auditDigest.startsWith("sha256:")); +assert.equal(clean.challenges[0].action, "release_withdrawal_closeout"); + +const risky = evaluateChallengeWithdrawal(riskyPacket); +assert.equal(risky.summary.decision, "escalate_withdrawal_dispute"); +assert.equal(risky.summary.challengeCount, 1); +assert.equal(risky.summary.affectedSolvers, 3); +assert.equal(risky.summary.recommendedReimbursementUsd, 2586); +assert.equal(risky.summary.fundedShortfallUsd, 2286); +assert.ok(risky.summary.findingCount >= 17); +assert.ok(risky.summary.criticalFindings >= 3); +assert.ok(risky.summary.highOrCriticalFindings >= 12); + +const findingCodes = new Set(risky.findings.map((finding) => finding.code)); +assert.ok(findingCodes.has("CANCELLATION_AUTHORITY_MISSING")); +assert.ok(findingCodes.has("SOLVER_WITHDRAWAL_NOTICE_MISSING")); +assert.ok(findingCodes.has("NOTICE_PARITY_GAP")); +assert.ok(findingCodes.has("COST_CLAIM_PATH_NOT_IN_NOTICE")); +assert.ok(findingCodes.has("MILESTONE_EVIDENCE_HASH_MISSING")); +assert.ok(findingCodes.has("COST_CLAIM_EVIDENCE_MISSING")); +assert.ok(findingCodes.has("NONREFUNDABLE_RECEIPT_MISSING")); +assert.ok(findingCodes.has("REIMBURSEMENT_SHORTFALL")); +assert.ok(findingCodes.has("REIMBURSEMENT_DECISION_MISSING")); +assert.ok(findingCodes.has("DISPUTE_HOLD_WINDOW_TOO_SHORT")); +assert.ok(findingCodes.has("REIMBURSEMENT_RESERVE_SHORTFALL")); +assert.ok(findingCodes.has("IP_DATA_RETURN_ATTESTATION_MISSING")); + +const firstDigest = evaluateChallengeWithdrawal(riskyPacket).summary.auditDigest; +const secondDigest = evaluateChallengeWithdrawal(riskyPacket).summary.auditDigest; +assert.equal(firstDigest, secondDigest); +assert.equal(sha256({ b: 2, a: 1 }), sha256({ a: 1, b: 2 })); + +console.log("challenge withdrawal reimbursement guard tests passed"); diff --git a/challenge-withdrawal-reimbursement-guard/verify-video.js b/challenge-withdrawal-reimbursement-guard/verify-video.js new file mode 100644 index 00000000..39af983c --- /dev/null +++ b/challenge-withdrawal-reimbursement-guard/verify-video.js @@ -0,0 +1,37 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const videoPath = path.join(__dirname, "reports", "demo.mp4"); +assert.ok(fs.existsSync(videoPath), "reports/demo.mp4 must exist"); +assert.ok(fs.statSync(videoPath).size > 5000, "reports/demo.mp4 should not be empty"); + +const probe = spawnSync(process.env.FFPROBE_PATH || "ffprobe", [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,width,height,r_frame_rate:format=duration", + "-of", + "json", + videoPath +], { encoding: "utf8" }); + +if (probe.status !== 0) { + process.stderr.write(probe.stderr || "ffprobe failed\n"); + process.exit(probe.status || 1); +} + +const metadata = JSON.parse(probe.stdout); +const stream = metadata.streams && metadata.streams[0]; +assert.equal(stream.codec_name, "h264"); +assert.equal(stream.width, 960); +assert.equal(stream.height, 540); +assert.equal(stream.r_frame_rate, "18/1"); + +const duration = Number(metadata.format && metadata.format.duration); +assert.ok(duration >= 3.9 && duration <= 4.2, `unexpected duration ${duration}`); + +console.log(`demo.mp4 verified: ${stream.codec_name}, ${stream.width}x${stream.height}, ${duration.toFixed(3)}s, ${stream.r_frame_rate}`);